feat: 实现业务视图页面
登录、模型调优、评测、推理、对比、模型管理、数据集、数据处理、工具、系统(硬件/日志/训练日志)等全部业务页面视图。
This commit is contained in:
145
frontend/src/views/compare/CompareChatView.vue
Normal file
145
frontend/src/views/compare/CompareChatView.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { getCompare } from '@/api/modules/compare'
|
||||||
|
import type { CompareTask, LoadedModel } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const taskId = route.params.id as string
|
||||||
|
|
||||||
|
const task = ref<CompareTask | null>(null)
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
systemPrompt: '',
|
||||||
|
question: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxTokens: 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadedModels = computed<LoadedModel[]>(() => {
|
||||||
|
if (!task.value?.load_status) return []
|
||||||
|
try {
|
||||||
|
const ls =
|
||||||
|
typeof task.value.load_status === 'string'
|
||||||
|
? JSON.parse(task.value.load_status)
|
||||||
|
: task.value.load_status
|
||||||
|
return ls.loaded_models || []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const allReady = computed(
|
||||||
|
() => loadedModels.value.length > 0 && loadedModels.value.every((m) => m.status === 'ready' || m.status === 'running'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isStarting = computed(() => loadedModels.value.some((m) => m.status === 'starting'))
|
||||||
|
|
||||||
|
async function loadTask() {
|
||||||
|
try {
|
||||||
|
task.value = await getCompare(taskId)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!form.question.trim()) {
|
||||||
|
ElMessage.warning('请输入问题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isStarting.value) {
|
||||||
|
ElMessage.warning('模型仍在启动中,请稍候')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 在新窗口打开结果页(原项目用 window.open)
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
taskId,
|
||||||
|
taskName: task.value?.model_name || task.value?.name || '',
|
||||||
|
question: form.question,
|
||||||
|
systemPrompt: form.systemPrompt,
|
||||||
|
temperature: String(form.temperature),
|
||||||
|
topP: String(form.topP),
|
||||||
|
topK: String(form.topK),
|
||||||
|
maxTokens: String(form.maxTokens),
|
||||||
|
})
|
||||||
|
const url = router.resolve(`/model-compare/result?${params.toString()}`).href
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTask()
|
||||||
|
pollTimer = setInterval(loadTask, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="模型对比配置">
|
||||||
|
<!-- 已启动模型 -->
|
||||||
|
<el-divider content-position="left">已启动模型</el-divider>
|
||||||
|
<div class="model-list">
|
||||||
|
<el-tag
|
||||||
|
v-for="(m, idx) in loadedModels"
|
||||||
|
:key="idx"
|
||||||
|
:type="m.status === 'ready' || m.status === 'running' ? 'success' : m.status === 'starting' ? 'warning' : 'danger'"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{{ m.model_name }} ({{ m.status }})
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!loadedModels.length" class="empty-hint">暂无已启动模型</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入配置 -->
|
||||||
|
<el-divider content-position="left">对话配置</el-divider>
|
||||||
|
<el-form label-width="120px" style="max-width: 700px">
|
||||||
|
<el-form-item label="系统提示词">
|
||||||
|
<el-input v-model="form.systemPrompt" type="textarea" :rows="3" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="问题">
|
||||||
|
<el-input v-model="form.question" type="textarea" :rows="4" placeholder="请输入要对比的问题" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Temperature">
|
||||||
|
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input style="max-width: 500px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Top-p">
|
||||||
|
<el-slider v-model="form.topP" :min="0" :max="1" :step="0.05" show-input style="max-width: 500px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Top-k">
|
||||||
|
<el-slider v-model="form.topK" :min="1" :max="100" :step="1" show-input style="max-width: 500px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Max Tokens">
|
||||||
|
<el-slider v-model="form.maxTokens" :min="256" :max="4096" :step="128" show-input style="max-width: 500px" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :disabled="!allReady || isStarting" @click="handleSubmit">
|
||||||
|
开始对比
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="router.back()">返回</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.model-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
225
frontend/src/views/compare/CompareResultView.vue
Normal file
225
frontend/src/views/compare/CompareResultView.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import MarkdownView from '@/components/MarkdownView.vue'
|
||||||
|
import {
|
||||||
|
getCompare,
|
||||||
|
chatWithPort,
|
||||||
|
batchChat,
|
||||||
|
} from '@/api/modules/compare'
|
||||||
|
import { getModelByName } from '@/api/modules/model'
|
||||||
|
import type { CompareTask, LoadedModel } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const taskId = route.query.taskId as string
|
||||||
|
const question = decodeURIComponent(route.query.question as string || '')
|
||||||
|
const systemPrompt = decodeURIComponent(route.query.systemPrompt as string || '')
|
||||||
|
const temperature = Number(route.query.temperature || 0.7)
|
||||||
|
const topP = Number(route.query.topP || 0.9)
|
||||||
|
const topK = Number(route.query.topK || 40)
|
||||||
|
const maxTokens = Number(route.query.maxTokens || 2048)
|
||||||
|
|
||||||
|
const taskName = route.query.taskName as string
|
||||||
|
|
||||||
|
interface ModelResult {
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
displayContent: string
|
||||||
|
status: 'loading' | 'done' | 'error'
|
||||||
|
stats?: { charsPerSec?: number; totalTime?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ref<ModelResult[]>([])
|
||||||
|
const started = ref(false)
|
||||||
|
|
||||||
|
const loadedModels = ref<LoadedModel[]>([])
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (started.value) return
|
||||||
|
started.value = true
|
||||||
|
try {
|
||||||
|
const task: any = await getCompare(taskId)
|
||||||
|
let models: LoadedModel[] = []
|
||||||
|
if (task.load_status) {
|
||||||
|
const ls = typeof task.load_status === 'string' ? JSON.parse(task.load_status) : task.load_status
|
||||||
|
models = ls.loaded_models || []
|
||||||
|
}
|
||||||
|
loadedModels.value = models
|
||||||
|
// 初始化结果占位
|
||||||
|
results.value = models.map((m) => ({
|
||||||
|
name: m.model_name || '模型',
|
||||||
|
content: '',
|
||||||
|
displayContent: '',
|
||||||
|
status: 'loading',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 并行推理
|
||||||
|
await Promise.all(models.map((m, idx) => inferOne(m, idx)))
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单模型推理(带超时) */
|
||||||
|
async function inferOne(model: LoadedModel, idx: number) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
try {
|
||||||
|
// 尝试通过端口代理调用
|
||||||
|
const res: any = await Promise.race([
|
||||||
|
chatWithPort({
|
||||||
|
port: model.port,
|
||||||
|
model_name: model.model_name,
|
||||||
|
messages: [
|
||||||
|
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
temperature,
|
||||||
|
top_p: topP,
|
||||||
|
top_k: topK,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = res?.response || res?.content || res?.data || JSON.stringify(res)
|
||||||
|
const totalTime = (Date.now() - startTime) / 1000
|
||||||
|
results.value[idx].content = content
|
||||||
|
results.value[idx].status = 'done'
|
||||||
|
results.value[idx].stats = {
|
||||||
|
totalTime,
|
||||||
|
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0,
|
||||||
|
}
|
||||||
|
// 模拟打字机效果
|
||||||
|
typewriterDisplay(idx, content)
|
||||||
|
} catch (e: any) {
|
||||||
|
results.value[idx].content = '推理失败: ' + (e.message || '')
|
||||||
|
results.value[idx].status = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打字机效果逐字展示 */
|
||||||
|
function typewriterDisplay(idx: number, content: string) {
|
||||||
|
let pos = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
pos += 2
|
||||||
|
results.value[idx].displayContent = content.slice(0, pos)
|
||||||
|
if (pos >= content.length) {
|
||||||
|
clearInterval(interval)
|
||||||
|
results.value[idx].displayContent = content
|
||||||
|
}
|
||||||
|
}, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="compare-result">
|
||||||
|
<div class="result-header">
|
||||||
|
<h2>对比结果{{ taskName ? ` - ${taskName}` : '' }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button @click="$router.push('/model-inference')">返回列表</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 问题 -->
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="question-box">
|
||||||
|
<template #title>
|
||||||
|
<strong>问题:</strong>{{ question }}
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<!-- 模型结果网格 -->
|
||||||
|
<div class="result-grid">
|
||||||
|
<el-card v-for="(r, idx) in results" :key="idx" shadow="hover" class="result-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="model-name">{{ r.name }}</span>
|
||||||
|
<el-tag v-if="r.status === 'loading'" type="warning" size="small">生成中...</el-tag>
|
||||||
|
<el-tag v-else-if="r.status === 'done'" type="success" size="small">完成</el-tag>
|
||||||
|
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
|
||||||
|
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
|
||||||
|
<div v-else class="loading-text">
|
||||||
|
<i class="fa fa-spinner fa-spin" /> 正在生成回答...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="r.stats" class="result-stats">
|
||||||
|
<span>耗时 {{ r.stats.totalTime?.toFixed(1) }}s</span>
|
||||||
|
<span>速度 {{ r.stats.charsPerSec }} 字/秒</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.compare-result {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-box {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.error-text {
|
||||||
|
color: #909399;
|
||||||
|
min-height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
762
frontend/src/views/data-process/DataProcessCreateView.vue
Normal file
762
frontend/src/views/data-process/DataProcessCreateView.vue
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
|
import TaskSetupStep from './create/TaskSetupStep.vue'
|
||||||
|
import PreviewCompareStep from './create/PreviewCompareStep.vue'
|
||||||
|
import GenerationStep from './create/GenerationStep.vue'
|
||||||
|
import ResultEditorStep from './create/ResultEditorStep.vue'
|
||||||
|
import { buildPreviewItems, createResults, DEFAULT_SOURCE_TEXT } from './create/previewModel'
|
||||||
|
import type { GenerationState, PreviewItem, ProcessType, ResultItem, StepId } from './create/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>()
|
||||||
|
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
|
||||||
|
|
||||||
|
const WIZARD_STEPS = [
|
||||||
|
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
|
||||||
|
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
|
||||||
|
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
|
||||||
|
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
|
||||||
|
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
|
||||||
|
|
||||||
|
const currentStep = ref(0)
|
||||||
|
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
|
||||||
|
const task = reactive({ name: '', description: '' })
|
||||||
|
const processType = ref<ProcessType>('unstructured')
|
||||||
|
interface UploadedDataFile {
|
||||||
|
uid: number | string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
count: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFiles = ref<UploadedDataFile[]>([])
|
||||||
|
|
||||||
|
const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', '))
|
||||||
|
const fileSize = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.size, 0))
|
||||||
|
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
|
||||||
|
const previewSignature = ref('')
|
||||||
|
const previewItems = ref<PreviewItem[]>([])
|
||||||
|
const selectedPreviewFileId = ref<string | null>(null)
|
||||||
|
const selectedPreviewId = ref<string | null>(null)
|
||||||
|
const selectedPreviewIdsByFile = ref<Record<string, string>>({})
|
||||||
|
const results = ref<ResultItem[]>([])
|
||||||
|
const selectedResultId = ref<string | null>(null)
|
||||||
|
const dirty = ref(false)
|
||||||
|
const restoringDraft = ref(false)
|
||||||
|
let allowLeave = false
|
||||||
|
|
||||||
|
const generation = reactive<GenerationState>({
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
|
||||||
|
})
|
||||||
|
|
||||||
|
let generationTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length)
|
||||||
|
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
|
||||||
|
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value))
|
||||||
|
const activeSourceText = computed(() => activePreviewFile.value?.content ?? '')
|
||||||
|
const previewFiles = computed(() => uploadedFiles.value.map((file) => {
|
||||||
|
const items = previewItems.value.filter((item) => item.sourceFileId === String(file.uid))
|
||||||
|
return {
|
||||||
|
id: String(file.uid),
|
||||||
|
name: file.name,
|
||||||
|
count: items.length,
|
||||||
|
modifiedCount: items.filter((item) => item.status !== 'original').length,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
const primaryActionLabel = computed(() => {
|
||||||
|
if (currentStepId.value === 'create') return '继续:数据预览'
|
||||||
|
if (currentStepId.value === 'preview') return '确认预览并继续'
|
||||||
|
if (currentStepId.value === 'results') return '保存任务'
|
||||||
|
if (generation.status === 'running') return '正在生成'
|
||||||
|
if (generation.status === 'success') return '查看生成结果'
|
||||||
|
if (generation.status === 'failed') return '重新生成'
|
||||||
|
return '开始生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryActionIcon = computed(() => {
|
||||||
|
if (currentStepId.value === 'results') return 'fa-check'
|
||||||
|
if (currentStepId.value === 'generate' && generation.status !== 'success') return 'fa-play'
|
||||||
|
return 'fa-arrow-right'
|
||||||
|
})
|
||||||
|
|
||||||
|
const previousStepLabel = computed(() => currentStep.value > 0
|
||||||
|
? WIZARD_STEPS[currentStep.value - 1].title
|
||||||
|
: '')
|
||||||
|
|
||||||
|
function draftSnapshot() {
|
||||||
|
return {
|
||||||
|
currentStep: currentStep.value,
|
||||||
|
task: { ...task },
|
||||||
|
processType: processType.value,
|
||||||
|
uploadedFiles: uploadedFiles.value,
|
||||||
|
previewSignature: previewSignature.value,
|
||||||
|
previewItems: previewItems.value,
|
||||||
|
selectedPreviewFileId: selectedPreviewFileId.value,
|
||||||
|
selectedPreviewId: selectedPreviewId.value,
|
||||||
|
selectedPreviewIdsByFile: selectedPreviewIdsByFile.value,
|
||||||
|
results: results.value,
|
||||||
|
selectedResultId: selectedResultId.value,
|
||||||
|
generation: { ...generation },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDraft() {
|
||||||
|
if (restoringDraft.value) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftSnapshot = ReturnType<typeof draftSnapshot> & {
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileCount?: number
|
||||||
|
sourceText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DRAFT_STORAGE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const snapshot = JSON.parse(raw) as DraftSnapshot
|
||||||
|
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
|
||||||
|
|
||||||
|
restoringDraft.value = true
|
||||||
|
currentStep.value = Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), WIZARD_STEPS.length - 1)
|
||||||
|
task.name = snapshot.task?.name || ''
|
||||||
|
task.description = snapshot.task?.description || ''
|
||||||
|
processType.value = snapshot.processType === 'structured' ? 'structured' : 'unstructured'
|
||||||
|
|
||||||
|
if (snapshot.uploadedFiles) {
|
||||||
|
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
|
||||||
|
} else if (snapshot.fileName && snapshot.sourceText) {
|
||||||
|
// Migrate old draft
|
||||||
|
uploadedFiles.value = [{
|
||||||
|
uid: 'migrated-draft',
|
||||||
|
name: snapshot.fileName,
|
||||||
|
size: Number(snapshot.fileSize) || 0,
|
||||||
|
count: Number(snapshot.fileCount) || 0,
|
||||||
|
content: snapshot.sourceText
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
previewSignature.value = snapshot.previewSignature || ''
|
||||||
|
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
|
||||||
|
previewItems.value = Array.isArray(snapshot.previewItems)
|
||||||
|
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
|
||||||
|
: []
|
||||||
|
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
|
||||||
|
selectedPreviewIdsByFile.value = snapshot.selectedPreviewIdsByFile || {}
|
||||||
|
selectedPreviewId.value = snapshot.selectedPreviewId
|
||||||
|
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|
||||||
|
|| activePreviewItems.value[0]?.id
|
||||||
|
|| null
|
||||||
|
results.value = Array.isArray(snapshot.results) ? snapshot.results : []
|
||||||
|
selectedResultId.value = snapshot.selectedResultId || results.value[0]?.id || null
|
||||||
|
Object.assign(generation, snapshot.generation || {})
|
||||||
|
dirty.value = false
|
||||||
|
nextTick(() => { restoringDraft.value = false })
|
||||||
|
ElMessage.info('已恢复上次保存的草稿')
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => task.name, () => task.description, processType],
|
||||||
|
() => {
|
||||||
|
if (!restoringDraft.value) dirty.value = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[currentStep, task, processType, uploadedFiles, previewSignature,
|
||||||
|
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
|
||||||
|
results, selectedResultId, generation],
|
||||||
|
persistDraft,
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function scrollToStepTop() {
|
||||||
|
document.querySelector<HTMLElement>('.layout-content')?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentStep, () => nextTick(scrollToStepTop))
|
||||||
|
|
||||||
|
async function handleFileChange(uploadFile: UploadFile) {
|
||||||
|
const raw = uploadFile.raw
|
||||||
|
if (!raw) return
|
||||||
|
if (raw.size > 200 * 1024 * 1024) {
|
||||||
|
ElMessage.warning('单文件不能超过 200MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = raw.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
const textExtensions = ['txt', 'md', 'json', 'jsonl', 'csv']
|
||||||
|
let content = ''
|
||||||
|
if (textExtensions.includes(extension)) {
|
||||||
|
try {
|
||||||
|
content = await raw.text()
|
||||||
|
} catch {
|
||||||
|
content = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileContent = content.trim() ? content : DEFAULT_SOURCE_TEXT
|
||||||
|
const linesCount = fileContent.split('\n').filter((line) => line.trim()).length
|
||||||
|
|
||||||
|
// Prevent duplicate upload of the same file
|
||||||
|
if (!uploadedFiles.value.some(f => f.name === raw.name && f.size === raw.size)) {
|
||||||
|
uploadedFiles.value.push({
|
||||||
|
uid: uploadFile.uid || Date.now() + Math.random(),
|
||||||
|
name: raw.name,
|
||||||
|
size: raw.size,
|
||||||
|
count: linesCount,
|
||||||
|
content: fileContent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSampleFile() {
|
||||||
|
uploadedFiles.value = [{
|
||||||
|
uid: 'sample-1',
|
||||||
|
name: 'finance_qa.jsonl',
|
||||||
|
size: 128 * 1024 * 1024,
|
||||||
|
count: DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length,
|
||||||
|
content: DEFAULT_SOURCE_TEXT
|
||||||
|
}]
|
||||||
|
if (!task.name) task.name = '金融问答清洗任务'
|
||||||
|
if (!task.description) task.description = '清洗金融领域问答数据,统一格式并生成高质量训练数据。'
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveFile(uid: string | number) {
|
||||||
|
const index = uploadedFiles.value.findIndex(f => f.uid === uid)
|
||||||
|
if (index > -1) {
|
||||||
|
uploadedFiles.value.splice(index, 1)
|
||||||
|
previewSignature.value = ''
|
||||||
|
previewItems.value = []
|
||||||
|
selectedPreviewId.value = null
|
||||||
|
resetDownstream()
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDownstream() {
|
||||||
|
stopGenerationTimer()
|
||||||
|
generation.status = 'idle'
|
||||||
|
generation.progress = 0
|
||||||
|
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
|
||||||
|
results.value = []
|
||||||
|
selectedResultId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextFromCreate() {
|
||||||
|
const valid = await taskSetupRef.value?.validate()
|
||||||
|
if (!valid) return
|
||||||
|
if (uploadedFiles.value.length === 0) {
|
||||||
|
ElMessage.warning('请上传至少一个源数据文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = `${processType.value}:${uploadedFiles.value.map((file) => `${file.uid}:${file.content}`).join('|')}`
|
||||||
|
if (signature !== previewSignature.value) {
|
||||||
|
previewItems.value = uploadedFiles.value.flatMap((file) =>
|
||||||
|
buildPreviewItems(file.content, processType.value, String(file.uid)),
|
||||||
|
)
|
||||||
|
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
|
||||||
|
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
|
||||||
|
selectedPreviewIdsByFile.value = selectedPreviewId.value && selectedPreviewFileId.value
|
||||||
|
? { [selectedPreviewFileId.value]: selectedPreviewId.value }
|
||||||
|
: {}
|
||||||
|
previewSignature.value = signature
|
||||||
|
resetDownstream()
|
||||||
|
}
|
||||||
|
currentStep.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreviewFile(fileId: string) {
|
||||||
|
if (selectedPreviewFileId.value && selectedPreviewId.value) {
|
||||||
|
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
|
||||||
|
}
|
||||||
|
selectedPreviewFileId.value = fileId
|
||||||
|
selectedPreviewId.value = selectedPreviewIdsByFile.value[fileId]
|
||||||
|
|| activePreviewItems.value[0]?.id
|
||||||
|
|| null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreviewItem(id: string) {
|
||||||
|
selectedPreviewId.value = id
|
||||||
|
if (selectedPreviewFileId.value) selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreviewContent(id: string, value: string) {
|
||||||
|
const item = previewItems.value.find((entry) => entry.id === id)
|
||||||
|
if (!item) return
|
||||||
|
item.editedContent = value
|
||||||
|
item.tokenCount = Math.max(1, Math.ceil(value.length / 2))
|
||||||
|
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePreviewItem(id: string) {
|
||||||
|
const item = previewItems.value.find((entry) => entry.id === id)
|
||||||
|
if (!item || item.sourceStart == null) return
|
||||||
|
item.editedContent = item.originalContent
|
||||||
|
item.tokenCount = Math.max(1, Math.ceil(item.originalContent.length / 2))
|
||||||
|
item.status = 'original'
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPreviewItem() {
|
||||||
|
if (!selectedPreviewFileId.value) return
|
||||||
|
const id = `manual-${Date.now()}`
|
||||||
|
previewItems.value.push({
|
||||||
|
id,
|
||||||
|
sourceFileId: selectedPreviewFileId.value,
|
||||||
|
originalContent: '',
|
||||||
|
editedContent: '',
|
||||||
|
sourceStart: null,
|
||||||
|
sourceEnd: null,
|
||||||
|
sourceStartLine: null,
|
||||||
|
sourceEndLine: null,
|
||||||
|
tokenCount: 1,
|
||||||
|
status: 'manual',
|
||||||
|
})
|
||||||
|
selectPreviewItem(id)
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePreviewItem(id: string) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('删除只影响本次处理,不会修改源文件。确认删除吗?', '删除预览内容', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = previewItems.value.findIndex((item) => item.id === id)
|
||||||
|
if (index < 0) return
|
||||||
|
previewItems.value.splice(index, 1)
|
||||||
|
selectedPreviewId.value = activePreviewItems.value[Math.min(index, activePreviewItems.value.length - 1)]?.id ?? null
|
||||||
|
if (selectedPreviewFileId.value && selectedPreviewId.value) {
|
||||||
|
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
|
||||||
|
}
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGenerationTimer() {
|
||||||
|
if (generationTimer) clearInterval(generationTimer)
|
||||||
|
generationTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGeneration() {
|
||||||
|
stopGenerationTimer()
|
||||||
|
generation.status = 'running'
|
||||||
|
generation.progress = 0
|
||||||
|
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
|
||||||
|
|
||||||
|
generationTimer = setInterval(() => {
|
||||||
|
generation.progress = Math.min(100, generation.progress + 8)
|
||||||
|
if (generation.progress < 100) return
|
||||||
|
|
||||||
|
stopGenerationTimer()
|
||||||
|
generation.status = 'success'
|
||||||
|
generation.message = `已完成 ${previewItems.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
|
||||||
|
results.value = createResults(previewItems.value)
|
||||||
|
selectedResultId.value = results.value[0]?.id ?? null
|
||||||
|
dirty.value = true
|
||||||
|
ElMessage.success('数据处理完成')
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGeneration() {
|
||||||
|
stopGenerationTimer()
|
||||||
|
generation.status = 'failed'
|
||||||
|
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
|
||||||
|
const item = results.value.find((entry) => entry.id === id)
|
||||||
|
if (!item) return
|
||||||
|
item[field] = value
|
||||||
|
const valid = item.instruction.trim() && item.output.trim()
|
||||||
|
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
|
||||||
|
const changed = item.instruction !== item.originalInstruction
|
||||||
|
|| item.input !== item.originalInput
|
||||||
|
|| item.output !== item.originalOutput
|
||||||
|
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreResult(id: string) {
|
||||||
|
const item = results.value.find((entry) => entry.id === id)
|
||||||
|
if (!item) return
|
||||||
|
item.instruction = item.originalInstruction
|
||||||
|
item.input = item.originalInput
|
||||||
|
item.output = item.originalOutput
|
||||||
|
item.error = undefined
|
||||||
|
item.status = 'valid'
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResults() {
|
||||||
|
let firstInvalidId: string | null = null
|
||||||
|
for (const item of results.value) {
|
||||||
|
if (!item.instruction.trim() || !item.output.trim()) {
|
||||||
|
item.error = 'Instruction 和 Output 不能为空'
|
||||||
|
item.status = 'invalid'
|
||||||
|
firstInvalidId ??= item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstInvalidId) selectedResultId.value = firstInvalidId
|
||||||
|
return firstInvalidId == null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrimaryAction() {
|
||||||
|
if (currentStepId.value === 'create') {
|
||||||
|
await nextFromCreate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStepId.value === 'preview') {
|
||||||
|
if (!previewItems.value.length) {
|
||||||
|
ElMessage.warning('当前没有可生成的预览内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentStep.value = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStepId.value === 'generate') {
|
||||||
|
if (generation.status === 'success') {
|
||||||
|
currentStep.value = 3
|
||||||
|
} else if (generation.status !== 'running') {
|
||||||
|
startGeneration()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await saveTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (generation.status === 'running') {
|
||||||
|
ElMessage.warning('请先停止当前生成任务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStep.value > 0) currentStep.value -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function saveTask() {
|
||||||
|
if (!validateResults()) {
|
||||||
|
ElMessage.warning('请先修正校验失败的结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dirty.value = false
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
allowLeave = true
|
||||||
|
ElMessage.success('数据处理任务已保存')
|
||||||
|
await router.push('/data-process')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!dirty.value) {
|
||||||
|
allowLeave = true
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前存在未保存修改,确定离开吗?', '离开创建任务', {
|
||||||
|
confirmButtonText: '放弃修改',
|
||||||
|
cancelButtonText: '继续编辑',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
allowLeave = true
|
||||||
|
router.back()
|
||||||
|
} catch {
|
||||||
|
// 用户继续编辑。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeRouteLeave((_to, _from, next) => {
|
||||||
|
if (allowLeave || !dirty.value) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(window.confirm('当前存在未保存修改,确定离开吗?'))
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(stopGenerationTimer)
|
||||||
|
onMounted(restoreDraft)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="create-wizard-layout">
|
||||||
|
<main class="wizard-main">
|
||||||
|
<div class="wizard-main-inner">
|
||||||
|
<div class="wizard-steps-container">
|
||||||
|
<div class="custom-wizard-steps">
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in WIZARD_STEPS"
|
||||||
|
:key="step.id"
|
||||||
|
class="step-item"
|
||||||
|
:class="{
|
||||||
|
'is-active': currentStep === index,
|
||||||
|
'is-completed': currentStep > index
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="index !== 0" class="step-connector"></div>
|
||||||
|
<div class="step-node">
|
||||||
|
<div class="step-icon">
|
||||||
|
<i v-if="currentStep > index" class="fa fa-check" />
|
||||||
|
<span v-else>{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-text">
|
||||||
|
<div class="step-title">{{ step.title }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-content">
|
||||||
|
<TaskSetupStep
|
||||||
|
v-if="currentStepId === 'create'"
|
||||||
|
ref="taskSetupRef"
|
||||||
|
v-model:name="task.name"
|
||||||
|
v-model:description="task.description"
|
||||||
|
v-model:process-type="processType"
|
||||||
|
:uploaded-files="uploadedFiles"
|
||||||
|
@file-change="handleFileChange"
|
||||||
|
@remove-file="handleRemoveFile"
|
||||||
|
@use-sample="useSampleFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewCompareStep
|
||||||
|
v-else-if="currentStepId === 'preview'"
|
||||||
|
:selected-id="selectedPreviewId"
|
||||||
|
:selected-file-id="selectedPreviewFileId"
|
||||||
|
:source-text="activeSourceText"
|
||||||
|
:items="activePreviewItems"
|
||||||
|
:process-type="processType"
|
||||||
|
:file-name="activePreviewFile?.name ?? ''"
|
||||||
|
:files="previewFiles"
|
||||||
|
@update:selected-id="selectPreviewItem"
|
||||||
|
@update:selected-file-id="selectPreviewFile"
|
||||||
|
@update:item-content="updatePreviewContent"
|
||||||
|
@restore:item="restorePreviewItem"
|
||||||
|
@add:item="addPreviewItem"
|
||||||
|
@remove:item="removePreviewItem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GenerationStep
|
||||||
|
v-else-if="currentStepId === 'generate'"
|
||||||
|
:task-name="task.name"
|
||||||
|
:process-type="processType"
|
||||||
|
:file-name="fileName"
|
||||||
|
:preview-count="previewItems.length"
|
||||||
|
:modified-count="modifiedPreviewCount"
|
||||||
|
:generation="generation"
|
||||||
|
@stop="stopGeneration"
|
||||||
|
@retry="startGeneration"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ResultEditorStep
|
||||||
|
v-else
|
||||||
|
v-model:selected-id="selectedResultId"
|
||||||
|
:items="results"
|
||||||
|
@update:field="updateResultField"
|
||||||
|
@restore:item="restoreResult"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="wizard-footer">
|
||||||
|
<div class="footer-left">
|
||||||
|
<el-button v-if="currentStep > 0" @click="handleBack">
|
||||||
|
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回:{{ previousStepLabel }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else @click="handleCancel">取消</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="footer-center">
|
||||||
|
</div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<el-button
|
||||||
|
class="wizard-primary-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="generation.status === 'running'"
|
||||||
|
:disabled="currentStepId === 'generate' && generation.status === 'running'"
|
||||||
|
@click="handlePrimaryAction"
|
||||||
|
>
|
||||||
|
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.create-wizard-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid #eef0f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px;
|
||||||
|
|
||||||
|
/* 自定义滚动条 */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-main-inner {
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-steps-container {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px dashed #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-wizard-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
margin: 0 16px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.is-completed .step-connector,
|
||||||
|
.step-item.is-active .step-connector {
|
||||||
|
background-color: #5146e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State: Active */
|
||||||
|
.step-item.is-active {
|
||||||
|
.step-icon {
|
||||||
|
border-color: #5146e5;
|
||||||
|
background-color: #eef2ff;
|
||||||
|
color: #5146e5;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State: Completed */
|
||||||
|
.step-item.is-completed {
|
||||||
|
.step-icon {
|
||||||
|
background-color: #5146e5;
|
||||||
|
border-color: #5146e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 64px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 32px;
|
||||||
|
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-primary-action {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
211
frontend/src/views/data-process/DataProcessListView.vue
Normal file
211
frontend/src/views/data-process/DataProcessListView.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||||
|
|
||||||
|
/** 数据处理任务类型 */
|
||||||
|
interface DataProcessTask {
|
||||||
|
id: number | string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
process_type: string
|
||||||
|
source_dataset: string
|
||||||
|
output_dataset?: string
|
||||||
|
create_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 接入真实接口前,先用本地 mock 数据
|
||||||
|
const router = useRouter()
|
||||||
|
const dataList = ref<DataProcessTask[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '客服问答数据清洗',
|
||||||
|
status: 'completed',
|
||||||
|
process_type: '去重清洗',
|
||||||
|
source_dataset: '客服对话原始集',
|
||||||
|
output_dataset: '客服对话清洗集',
|
||||||
|
create_time: '2026-07-08 14:23:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '指令微调数据构造',
|
||||||
|
status: 'running',
|
||||||
|
process_type: '指令构造',
|
||||||
|
source_dataset: '通用语料库',
|
||||||
|
output_dataset: 'SFT 指令集',
|
||||||
|
create_time: '2026-07-09 09:10:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '敏感信息脱敏处理',
|
||||||
|
status: 'pending',
|
||||||
|
process_type: '脱敏处理',
|
||||||
|
source_dataset: '用户反馈数据',
|
||||||
|
create_time: '2026-07-09 16:45:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '多轮对话拼接',
|
||||||
|
status: 'failed',
|
||||||
|
process_type: '格式转换',
|
||||||
|
source_dataset: '单轮问答集',
|
||||||
|
create_time: '2026-07-10 08:30:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeTab = ref('all')
|
||||||
|
|
||||||
|
const filteredDataList = computed(() => {
|
||||||
|
let list = dataList.value
|
||||||
|
|
||||||
|
if (activeTab.value !== 'all') {
|
||||||
|
if (activeTab.value === 'processing') {
|
||||||
|
list = list.filter(item => item.status === 'running' || item.status === 'pending')
|
||||||
|
} else if (activeTab.value === 'completed') {
|
||||||
|
list = list.filter(item => item.status === 'completed')
|
||||||
|
} else if (activeTab.value === 'failed') {
|
||||||
|
list = list.filter(item => item.status === 'failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 新建数据处理任务 */
|
||||||
|
function handleCreate() {
|
||||||
|
router.push('/data-process/create')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看任务详情(功能开发中) */
|
||||||
|
function viewDetail(_row: DataProcessTask) {
|
||||||
|
ElMessage.info('查看详情功能开发中...')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务(功能开发中) */
|
||||||
|
function handleDelete(_row: DataProcessTask) {
|
||||||
|
ElMessage.info('删除功能开发中...')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-process-page" style="height: 100%;">
|
||||||
|
<DataTablePage
|
||||||
|
title=""
|
||||||
|
:data="filteredDataList"
|
||||||
|
searchable
|
||||||
|
:search-fields="['name']"
|
||||||
|
create-text="新建数据处理任务"
|
||||||
|
create-to="/data-process/create"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<!-- 胶囊切换控件 -->
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button
|
||||||
|
class="capsule-tab-item"
|
||||||
|
:class="{ active: activeTab === 'all' }"
|
||||||
|
@click="activeTab = 'all'"
|
||||||
|
>
|
||||||
|
全部任务
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="capsule-tab-item"
|
||||||
|
:class="{ active: activeTab === 'processing' }"
|
||||||
|
@click="activeTab = 'processing'"
|
||||||
|
>
|
||||||
|
处理中
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="capsule-tab-item"
|
||||||
|
:class="{ active: activeTab === 'completed' }"
|
||||||
|
@click="activeTab = 'completed'"
|
||||||
|
>
|
||||||
|
已完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="任务名称" prop="name" align="center" show-overflow-tooltip />
|
||||||
|
<el-table-column label="任务状态" align="center" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ModelStatusTag :status="row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="处理类型" align="center" width="140">
|
||||||
|
<template #default="{ row }">{{ row.process_type || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="源数据集" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.source_dataset || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输出数据集" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.output_dataset || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" align="center" width="190">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" link size="small" @click="viewDetail(row)">
|
||||||
|
<i class="fa fa-file-text-o" 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>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
frontend/src/views/data-process/create/GenerationStep.vue
Normal file
215
frontend/src/views/data-process/create/GenerationStep.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { GenerationState, ProcessType } from './types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
taskName: string
|
||||||
|
processType: ProcessType
|
||||||
|
fileName: string
|
||||||
|
previewCount: number
|
||||||
|
modifiedCount: number
|
||||||
|
generation: GenerationState
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
stop: []
|
||||||
|
retry: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="generation-step">
|
||||||
|
<div class="generation-layout">
|
||||||
|
<div class="summary-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>任务摘要</strong>
|
||||||
|
<span>已完成预览确认</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>任务名称</dt><dd>{{ taskName }}</dd></div>
|
||||||
|
<div><dt>数据类型</dt><dd>{{ processType === 'unstructured' ? '非结构化数据' : '结构化数据' }}</dd></div>
|
||||||
|
<div><dt>源文件</dt><dd>{{ fileName }}</dd></div>
|
||||||
|
<div><dt>预览条目</dt><dd>{{ previewCount.toLocaleString() }} 条</dd></div>
|
||||||
|
<div><dt>已修改</dt><dd>{{ modifiedCount.toLocaleString() }} 条</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="run-panel" :class="`is-${generation.status}`">
|
||||||
|
<div class="run-icon">
|
||||||
|
<i v-if="generation.status === 'success'" class="fa fa-check" />
|
||||||
|
<i v-else-if="generation.status === 'failed'" class="fa fa-exclamation" />
|
||||||
|
<i v-else-if="generation.status === 'running'" class="fa fa-cog fa-spin" />
|
||||||
|
<i v-else class="fa fa-play" />
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
{{ generation.status === 'idle' ? '准备开始处理'
|
||||||
|
: generation.status === 'running' ? '正在生成数据'
|
||||||
|
: generation.status === 'success' ? '数据生成完成'
|
||||||
|
: '生成已停止' }}
|
||||||
|
</h3>
|
||||||
|
<p>{{ generation.message }}</p>
|
||||||
|
<el-progress
|
||||||
|
v-if="generation.status !== 'idle'"
|
||||||
|
:percentage="generation.progress"
|
||||||
|
:stroke-width="10"
|
||||||
|
:status="generation.status === 'success' ? 'success' : undefined"
|
||||||
|
/>
|
||||||
|
<div class="run-meta">
|
||||||
|
<span>解析源数据</span>
|
||||||
|
<span>应用预览修改</span>
|
||||||
|
<span>生成标准结果</span>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="generation.status === 'running'" plain type="warning" @click="emit('stop')">
|
||||||
|
停止生成
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="generation.status === 'failed'" plain type="primary" @click="emit('retry')">
|
||||||
|
重新生成
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.generation-step {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.generation-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.78fr) minmax(420px, 1.22fr);
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel,
|
||||||
|
.run-panel {
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px 17px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #2ca66a;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 17px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 13px 0;
|
||||||
|
border-bottom: 1px solid #eef0f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dt,
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #8a93a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
max-width: 65%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #344054;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-panel {
|
||||||
|
display: flex;
|
||||||
|
min-height: 360px;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 34px 48px;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
color: #2e3646;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
min-height: 22px;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: #7b8495;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-progress) {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
color: #5b50f2;
|
||||||
|
font-size: 22px;
|
||||||
|
background: #f0efff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-panel.is-success .run-icon {
|
||||||
|
color: #2ca66a;
|
||||||
|
background: #eaf8f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-panel.is-failed .run-icon {
|
||||||
|
color: #d97706;
|
||||||
|
background: #fff7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.generation-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
585
frontend/src/views/data-process/create/PreviewCompareStep.vue
Normal file
585
frontend/src/views/data-process/create/PreviewCompareStep.vue
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { sourceLines } from './previewModel'
|
||||||
|
import type { PreviewItem, ProcessType } from './types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sourceText: string
|
||||||
|
items: PreviewItem[]
|
||||||
|
selectedId: string | null
|
||||||
|
processType: ProcessType
|
||||||
|
fileName: string
|
||||||
|
files: { id: string; name: string; count: number; modifiedCount: number }[]
|
||||||
|
selectedFileId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedId': [value: string]
|
||||||
|
'update:selectedFileId': [value: string]
|
||||||
|
'update:item-content': [id: string, value: string]
|
||||||
|
'remove:item': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sourceViewerRef = ref<HTMLElement | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const PREVIEW_PAGE_SIZE = 6
|
||||||
|
const editingItemId = ref<string | null>(null)
|
||||||
|
const editorDraft = ref('')
|
||||||
|
const lines = computed(() => sourceLines(props.sourceText))
|
||||||
|
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||||
|
const editingItem = computed(() => props.items.find((item) => item.id === editingItemId.value))
|
||||||
|
|
||||||
|
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||||
|
const matchesSearch = !search.value.trim()
|
||||||
|
|| item.editedContent.toLowerCase().includes(search.value.trim().toLowerCase())
|
||||||
|
|| String(index + 1).includes(search.value.trim())
|
||||||
|
return matchesSearch
|
||||||
|
}))
|
||||||
|
|
||||||
|
const pagedItems = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * PREVIEW_PAGE_SIZE
|
||||||
|
return filteredItems.value.slice(start, start + PREVIEW_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
|
||||||
|
|
||||||
|
function isLineHighlighted(lineStart: number, lineEnd: number) {
|
||||||
|
const item = selectedItem.value
|
||||||
|
if (!item || item.sourceStart == null || item.sourceEnd == null) return false
|
||||||
|
return lineEnd >= item.sourceStart && lineStart <= item.sourceEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(id: string) {
|
||||||
|
emit('update:selectedId', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(item: PreviewItem) {
|
||||||
|
selectItem(item.id)
|
||||||
|
editingItemId.value = item.id
|
||||||
|
editorDraft.value = item.editedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editingItemId.value = null
|
||||||
|
editorDraft.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEditor() {
|
||||||
|
if (!editingItem.value) return
|
||||||
|
emit('update:item-content', editingItem.value.id, editorDraft.value)
|
||||||
|
closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: PreviewItem) {
|
||||||
|
selectItem(item.id)
|
||||||
|
emit('remove:item', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange() {
|
||||||
|
closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
closeEditor()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.selectedFileId, closeEditor)
|
||||||
|
|
||||||
|
watch(selectedItem, async (item) => {
|
||||||
|
if (!item) return
|
||||||
|
const visibleIndex = filteredItems.value.findIndex((entry) => entry.id === item.id)
|
||||||
|
if (visibleIndex >= 0) {
|
||||||
|
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.sourceStart == null) return
|
||||||
|
await nextTick()
|
||||||
|
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
|
||||||
|
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
|
||||||
|
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function itemNumber(item: PreviewItem) {
|
||||||
|
return props.items.findIndex((entry) => entry.id === item.id) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineRange(item: PreviewItem) {
|
||||||
|
if (item.sourceStartLine == null || item.sourceEndLine == null) return '手动新增,无源文件定位'
|
||||||
|
return item.sourceStartLine === item.sourceEndLine
|
||||||
|
? `来源:第 ${item.sourceStartLine} 行`
|
||||||
|
: `来源:第 ${item.sourceStartLine}–${item.sourceEndLine} 行`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="preview-step">
|
||||||
|
<div class="preview-file-switcher">
|
||||||
|
<div class="file-switcher-control">
|
||||||
|
<span>当前文件</span>
|
||||||
|
<el-select
|
||||||
|
:model-value="selectedFileId"
|
||||||
|
filterable
|
||||||
|
placeholder="选择文件"
|
||||||
|
aria-label="选择当前预览文件"
|
||||||
|
@update:model-value="emit('update:selectedFileId', $event)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
:label="file.name"
|
||||||
|
:value="file.id"
|
||||||
|
>
|
||||||
|
<div class="file-option">
|
||||||
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
|
<span>{{ file.count.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}</span>
|
||||||
|
<em v-if="file.modifiedCount">{{ file.modifiedCount }} 处已修改</em>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<span class="file-switcher-summary">
|
||||||
|
{{ files.length }} 个文件 · 当前文件 {{ items.length.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-workspace">
|
||||||
|
<div class="source-pane">
|
||||||
|
<div class="pane-header">
|
||||||
|
<div>
|
||||||
|
<strong>源文件 · {{ fileName }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="sourceViewerRef" class="source-viewer" tabindex="0" aria-label="源文件内容">
|
||||||
|
<div
|
||||||
|
v-for="line in lines"
|
||||||
|
:key="line.number"
|
||||||
|
class="source-line"
|
||||||
|
:class="{ 'is-highlighted': isLineHighlighted(line.start, line.end) }"
|
||||||
|
:data-source-start="line.start"
|
||||||
|
>
|
||||||
|
<span class="line-number">{{ line.number }}</span>
|
||||||
|
<span class="line-content">{{ line.content || ' ' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-pane">
|
||||||
|
<div class="pane-header">
|
||||||
|
<strong>{{ processType === 'unstructured' ? '切片内容' : '记录内容' }}</strong>
|
||||||
|
<span>共 {{ items.length.toLocaleString() }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!editingItem">
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<el-input v-model="search" clearable placeholder="搜索编号或内容" size="small">
|
||||||
|
<template #prefix><i class="fa fa-search" /></template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-list" aria-label="预览条目列表">
|
||||||
|
<div
|
||||||
|
v-for="item in pagedItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="preview-item"
|
||||||
|
:class="{ 'is-active': item.id === selectedItem?.id }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="selectItem(item.id)"
|
||||||
|
@keydown.enter="selectItem(item.id)"
|
||||||
|
@keydown.space.prevent="selectItem(item.id)"
|
||||||
|
>
|
||||||
|
<span class="item-name">{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(item)).padStart(3, '0') }}</span>
|
||||||
|
<span class="item-source">{{ lineRange(item) }}</span>
|
||||||
|
<span class="item-actions">
|
||||||
|
<el-button link aria-label="编辑切片" title="编辑" @click.stop="openEditor(item)">
|
||||||
|
<i class="fa fa-pencil" />
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" aria-label="删除切片" title="删除" @click.stop="removeItem(item)">
|
||||||
|
<i class="fa fa-trash-o" />
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!filteredItems.length" class="empty-result">没有符合条件的内容</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-if="filteredItems.length > PREVIEW_PAGE_SIZE"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="PREVIEW_PAGE_SIZE"
|
||||||
|
:total="filteredItems.length"
|
||||||
|
:pager-count="5"
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
class="preview-pagination"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="preview-editor">
|
||||||
|
<div class="editor-heading">
|
||||||
|
<div>
|
||||||
|
<strong>{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(editingItem)).padStart(3, '0') }} 正文</strong>
|
||||||
|
<small>{{ lineRange(editingItem) }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="editorDraft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
resize="none"
|
||||||
|
/>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<div>
|
||||||
|
<el-button @click="closeEditor">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveEditor">保存修改</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preview-step {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-file-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-switcher-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
flex: none;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select) {
|
||||||
|
width: min(360px, 42vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-switcher-summary {
|
||||||
|
color: #7d8798;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 440px;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
span,
|
||||||
|
em {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
color: #5549dc;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr);
|
||||||
|
height: clamp(560px, calc(100vh - 370px), 720px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pane,
|
||||||
|
.preview-pane {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pane {
|
||||||
|
border-right: 1px solid #e5e8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0 15px;
|
||||||
|
color: #313949;
|
||||||
|
background: #fbfcfe;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-viewer {
|
||||||
|
flex: 1;
|
||||||
|
height: 538px;
|
||||||
|
padding: 12px 0 24px;
|
||||||
|
overflow: auto;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
min-height: 29px;
|
||||||
|
color: #424b5d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background-color 0.18s ease, border-color 0.18s ease;
|
||||||
|
|
||||||
|
&.is-highlighted {
|
||||||
|
background: #eeedff;
|
||||||
|
border-left-color: #5b50f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-number {
|
||||||
|
padding-right: 11px;
|
||||||
|
color: #a0a7b4;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-content {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 14px 2px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #edf0f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 94px minmax(130px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #6b7382;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom-color: #edf0f5;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fafaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: #3f36c8;
|
||||||
|
background: #f6f5ff;
|
||||||
|
border-color: #5b50f2;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-source,
|
||||||
|
.item-actions {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-result {
|
||||||
|
padding: 34px 16px;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-editor {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 13px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-editor :deep(.el-textarea__inner) {
|
||||||
|
min-height: 260px !important;
|
||||||
|
color: #3f4756;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.preview-workspace {
|
||||||
|
grid-template-columns: minmax(0, 52fr) minmax(360px, 48fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.preview-file-switcher {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-switcher-control {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-select) {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-switcher-summary {
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pane {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #e5e8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-viewer {
|
||||||
|
height: 320px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
315
frontend/src/views/data-process/create/ResultEditorStep.vue
Normal file
315
frontend/src/views/data-process/create/ResultEditorStep.vue
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { ResultItem } from './types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ResultItem[]
|
||||||
|
selectedId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedId': [value: string]
|
||||||
|
'update:field': [id: string, field: 'instruction' | 'input' | 'output', value: string]
|
||||||
|
'restore:item': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const invalidOnly = ref(false)
|
||||||
|
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||||
|
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
|
||||||
|
|
||||||
|
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||||
|
const keyword = search.value.trim().toLowerCase()
|
||||||
|
const matchesSearch = !keyword
|
||||||
|
|| item.instruction.toLowerCase().includes(keyword)
|
||||||
|
|| item.output.toLowerCase().includes(keyword)
|
||||||
|
|| String(index + 1).includes(keyword)
|
||||||
|
return matchesSearch && (!invalidOnly.value || item.status === 'invalid')
|
||||||
|
}))
|
||||||
|
|
||||||
|
function selectRelative(offset: number) {
|
||||||
|
if (!props.items.length) return
|
||||||
|
const nextIndex = Math.min(Math.max(selectedIndex.value + offset, 0), props.items.length - 1)
|
||||||
|
emit('update:selectedId', props.items[nextIndex].id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="result-step">
|
||||||
|
<div class="result-workspace">
|
||||||
|
<aside class="result-list-pane">
|
||||||
|
<div class="pane-header"><strong>生成结果</strong><span>共 {{ items.length }} 条</span></div>
|
||||||
|
<div class="result-toolbar">
|
||||||
|
<el-input v-model="search" clearable size="small" placeholder="搜索结果">
|
||||||
|
<template #prefix><i class="fa fa-search" /></template>
|
||||||
|
</el-input>
|
||||||
|
<el-checkbox v-model="invalidOnly">仅看错误</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="result-list">
|
||||||
|
<button
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="result-item"
|
||||||
|
:class="{ 'is-active': item.id === selectedItem?.id }"
|
||||||
|
@click="emit('update:selectedId', item.id)"
|
||||||
|
>
|
||||||
|
<span class="result-index">#{{ String(items.findIndex((entry) => entry.id === item.id) + 1).padStart(3, '0') }}</span>
|
||||||
|
<span class="result-copy">
|
||||||
|
<strong>{{ item.instruction || '未填写指令' }}</strong>
|
||||||
|
<small>{{ item.output || '未填写输出' }}</small>
|
||||||
|
</span>
|
||||||
|
<i class="fa" :class="item.status === 'invalid' ? 'fa-exclamation-circle is-error' : 'fa-check-circle is-valid'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div v-if="selectedItem" class="result-editor-pane">
|
||||||
|
<div class="pane-header">
|
||||||
|
<div>
|
||||||
|
<strong>结果 #{{ String(selectedIndex + 1).padStart(3, '0') }}</strong>
|
||||||
|
<span v-if="selectedItem.status === 'modified'" class="modified-label">已修改</span>
|
||||||
|
</div>
|
||||||
|
<el-button link @click="emit('restore:item', selectedItem.id)"><i class="fa fa-undo" /> 恢复生成结果</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-editor">
|
||||||
|
<label>Instruction <em>必填</em></label>
|
||||||
|
<el-input
|
||||||
|
:model-value="selectedItem.instruction"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
@update:model-value="emit('update:field', selectedItem.id, 'instruction', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field-editor">
|
||||||
|
<label>Input <span>选填</span></label>
|
||||||
|
<el-input
|
||||||
|
:model-value="selectedItem.input"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
@update:model-value="emit('update:field', selectedItem.id, 'input', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field-editor">
|
||||||
|
<label>Output <em>必填</em></label>
|
||||||
|
<el-input
|
||||||
|
:model-value="selectedItem.output"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
@update:model-value="emit('update:field', selectedItem.id, 'output', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedItem.error" class="validation-error">
|
||||||
|
<i class="fa fa-exclamation-circle" /> {{ selectedItem.error }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="validation-success">
|
||||||
|
<i class="fa fa-check-circle" /> 字段校验通过
|
||||||
|
</div>
|
||||||
|
<div class="editor-pagination">
|
||||||
|
<el-button :disabled="selectedIndex <= 0" @click="selectRelative(-1)">上一条</el-button>
|
||||||
|
<span>{{ selectedIndex + 1 }} / {{ items.length }}</span>
|
||||||
|
<el-button :disabled="selectedIndex >= items.length - 1" @click="selectRelative(1)">下一条</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.result-step {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.result-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 34fr) minmax(480px, 66fr);
|
||||||
|
min-height: clamp(420px, calc(100vh - 500px), 590px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list-pane {
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid #e5e8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0 15px;
|
||||||
|
color: #344054;
|
||||||
|
background: #fbfcfe;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span,
|
||||||
|
.modified-label {
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #edf0f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
height: 476px;
|
||||||
|
padding: 7px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr) 18px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 9px;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom-color: #edf0f5;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
background: #f7f6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
border-color: #5b50f2;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-index {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-copy {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-valid {
|
||||||
|
color: #2ca66a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-error {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-editor-pane {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-editor {
|
||||||
|
padding: 13px 18px 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
color: #e05252;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error,
|
||||||
|
.validation-success {
|
||||||
|
margin: 12px 18px 0;
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error {
|
||||||
|
color: #b45309;
|
||||||
|
background: #fff7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-success {
|
||||||
|
color: #25895c;
|
||||||
|
background: #edf9f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.result-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list-pane {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #e5e8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
469
frontend/src/views/data-process/create/TaskSetupStep.vue
Normal file
469
frontend/src/views/data-process/create/TaskSetupStep.vue
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||||
|
import type { ProcessType } from './types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
processType: ProcessType
|
||||||
|
uploadedFiles: { uid: string | number; name: string; size: number; count: number }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:name': [value: string]
|
||||||
|
'update:description': [value: string]
|
||||||
|
'update:processType': [value: ProcessType]
|
||||||
|
'file-change': [file: UploadFile]
|
||||||
|
'remove-file': [uid: string | number]
|
||||||
|
'use-sample': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const formModel = computed(() => ({
|
||||||
|
name: props.name,
|
||||||
|
processType: props.processType,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入任务名称', trigger: 'blur' },
|
||||||
|
{ max: 50, message: '任务名称不能超过 50 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadAccept = computed(() => props.processType === 'unstructured'
|
||||||
|
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
|
||||||
|
: '.json,.jsonl,.csv,.xlsx,.xls')
|
||||||
|
|
||||||
|
const FILE_PAGE_SIZE = 10
|
||||||
|
const currentFilePage = ref(1)
|
||||||
|
const pagedUploadedFiles = computed(() => {
|
||||||
|
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
|
||||||
|
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
|
||||||
|
if (newLength > oldLength) {
|
||||||
|
currentFilePage.value = totalPages
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSize(size: number) {
|
||||||
|
if (!size) return '0 KB'
|
||||||
|
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
if (!formRef.value) return false
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validate })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="task-setup-step">
|
||||||
|
<el-form ref="formRef" :model="formModel" :rules="rules" label-position="top">
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<div class="basic-grid">
|
||||||
|
<el-form-item label="任务名称" prop="name" required>
|
||||||
|
<el-input
|
||||||
|
:model-value="name"
|
||||||
|
maxlength="50"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="例如:金融问答清洗任务"
|
||||||
|
@update:model-value="emit('update:name', $event)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务描述(选填)">
|
||||||
|
<el-input
|
||||||
|
:model-value="description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="简要说明本次数据处理目标"
|
||||||
|
@update:model-value="emit('update:description', $event)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>处理类型</h3>
|
||||||
|
<p>类型只影响后续预览方式,不会改变四步流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form-item prop="processType" class="type-form-item">
|
||||||
|
<div class="type-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="type-option"
|
||||||
|
:class="{ 'is-active': processType === 'structured' }"
|
||||||
|
@click="emit('update:processType', 'structured')"
|
||||||
|
>
|
||||||
|
<span class="type-icon"><i class="fa fa-table" /></span>
|
||||||
|
<span>
|
||||||
|
<strong>结构化数据</strong>
|
||||||
|
<small>适用于 CSV、Excel、JSONL 等固定字段数据</small>
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-check-circle selection-mark" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="type-option"
|
||||||
|
:class="{ 'is-active': processType === 'unstructured' }"
|
||||||
|
@click="emit('update:processType', 'unstructured')"
|
||||||
|
>
|
||||||
|
<span class="type-icon"><i class="fa fa-file-text-o" /></span>
|
||||||
|
<span>
|
||||||
|
<strong>非结构化数据</strong>
|
||||||
|
<small>适用于文档、文本、问答等需要切分的数据</small>
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-check-circle selection-mark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section upload-section">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>源数据上传</h3>
|
||||||
|
<p>系统将在下一步生成可对照编辑的预览内容,支持上传多个文件</p>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="uploadedFiles.length === 0" link type="primary" @click="emit('use-sample')">
|
||||||
|
使用示例数据
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-upload
|
||||||
|
v-if="uploadedFiles.length === 0"
|
||||||
|
drag
|
||||||
|
multiple
|
||||||
|
:accept="uploadAccept"
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-cloud-upload upload-icon" />
|
||||||
|
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
{{ processType === 'unstructured'
|
||||||
|
? '支持 TXT、Markdown、PDF、Word、JSON、JSONL,单文件不超过 200MB'
|
||||||
|
: '支持 JSON、JSONL、CSV、Excel,单文件不超过 200MB' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
|
||||||
|
<div class="uploaded-file-list-header">
|
||||||
|
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
|
||||||
|
<div class="continue-upload">
|
||||||
|
<el-upload
|
||||||
|
multiple
|
||||||
|
:accept="uploadAccept"
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||||
|
>
|
||||||
|
<el-button size="small" type="primary">继续上传</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uploaded-file-items">
|
||||||
|
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
|
||||||
|
<span class="file-icon"><i class="fa fa-file-text-o" /></span>
|
||||||
|
<div class="file-main">
|
||||||
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
|
<span>{{ formatSize(file.size) }}<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template></span>
|
||||||
|
</div>
|
||||||
|
<span class="file-status"><i class="fa fa-check-circle" /> 校验通过</span>
|
||||||
|
<el-button link type="danger" @click="emit('remove-file', file.uid)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
|
||||||
|
v-model:current-page="currentFilePage"
|
||||||
|
:page-size="FILE_PAGE_SIZE"
|
||||||
|
:total="uploadedFiles.length"
|
||||||
|
:pager-count="5"
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
class="uploaded-file-pagination"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-setup-step {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 0 0 26px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
border-bottom: 1px solid #edf0f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #2f3747;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.basic-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-form-item {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 18px;
|
||||||
|
color: #4b5563;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe3ea;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #a8a3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: #fafaff;
|
||||||
|
border-color: #5b50f2;
|
||||||
|
box-shadow: 0 0 0 1px rgba(91, 80, 242, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #262d3d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #7b8495;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon,
|
||||||
|
.file-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #5b50f2;
|
||||||
|
font-size: 18px;
|
||||||
|
background: #f0efff;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
color: #5b50f2;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option.is-active .selection-mark {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section :deep(.el-upload) {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section :deep(.el-upload-dragger) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 154px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
border-color: #dfe3ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #5b50f2;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe3ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #5f6878;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
border-bottom: 1px solid #edf0f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-upload {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-upload :deep(.el-upload) {
|
||||||
|
width: auto;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #edf0f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #edf0f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #273142;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
color: #2ca66a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file :deep(.el-button) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.type-options {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
frontend/src/views/data-process/create/previewModel.ts
Normal file
89
frontend/src/views/data-process/create/previewModel.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { PreviewItem, ProcessType, ResultItem, SourceLine } from './types'
|
||||||
|
|
||||||
|
export const DEFAULT_SOURCE_TEXT = [
|
||||||
|
'问:如何看待当前的通货膨胀风险?',
|
||||||
|
'答:当前通胀水平总体可控,但仍需关注能源价格与供给扰动。',
|
||||||
|
'问:美联储下一次议息会议何时召开?',
|
||||||
|
'答:会议时间以美联储官方日历为准,市场会重点关注利率路径指引。',
|
||||||
|
'问:人民币汇率未来走势如何?',
|
||||||
|
'答:人民币汇率取决于中美利差、经济基本面与政策预期。',
|
||||||
|
'问:银行理财产品收益率为何持续走低?',
|
||||||
|
'答:主要与市场利率下行、资产端收益下降以及风险偏好变化有关。',
|
||||||
|
'问:什么是复利?',
|
||||||
|
'答:复利是指在计算利息时,将上一期利息加入本金,再计算下一期利息。',
|
||||||
|
'问:如何评估股票的投资价值?',
|
||||||
|
'答:评估股票投资价值可以从以下几个方面进行:',
|
||||||
|
'1. 公司基本面:分析公司的财务状况、盈利能力、成长性等。',
|
||||||
|
'2. 行业前景:考察公司所处行业的发展趋势和竞争格局。',
|
||||||
|
'3. 估值水平:通过市盈率、市净率等指标判断估值是否合理。',
|
||||||
|
'4. 财务健康:关注公司的负债情况、现金流状况等。',
|
||||||
|
'5. 管理团队:评估管理层的能力和过往业绩。',
|
||||||
|
'此外,还需要关注宏观经济环境、政策变化等因素对股票市场的影响。',
|
||||||
|
'问:债券和股票的主要区别是什么?',
|
||||||
|
'答:债券收益相对稳定但上行有限,股票波动更大且承担更高风险。',
|
||||||
|
'问:什么是市盈率?',
|
||||||
|
'答:市盈率是股票价格与每股收益的比值,常用于衡量估值水平。',
|
||||||
|
'问:如何进行资产配置?',
|
||||||
|
'答:应根据投资目标、风险承受能力和市场环境合理分配资产。',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
export function sourceLines(sourceText: string): SourceLine[] {
|
||||||
|
const rawLines = sourceText.split('\n')
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
return rawLines.map((content, index) => {
|
||||||
|
const start = cursor
|
||||||
|
const end = start + content.length
|
||||||
|
cursor = end + (index < rawLines.length - 1 ? 1 : 0)
|
||||||
|
return { number: index + 1, content, start, end }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId = 'default-source'): PreviewItem[] {
|
||||||
|
const meaningfulLines = sourceLines(sourceText).filter((line) => line.content.trim())
|
||||||
|
const groupSize = processType === 'structured' ? 1 : 3
|
||||||
|
const items: PreviewItem[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < meaningfulLines.length; index += groupSize) {
|
||||||
|
const group = meaningfulLines.slice(index, index + groupSize)
|
||||||
|
if (!group.length) continue
|
||||||
|
|
||||||
|
const sourceStart = group[0].start
|
||||||
|
const sourceEnd = group[group.length - 1].end
|
||||||
|
const content = sourceText.slice(sourceStart, sourceEnd)
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `preview-${sourceFileId}-${items.length + 1}`,
|
||||||
|
sourceFileId,
|
||||||
|
originalContent: content,
|
||||||
|
editedContent: content,
|
||||||
|
sourceStart,
|
||||||
|
sourceEnd,
|
||||||
|
sourceStartLine: group[0].number,
|
||||||
|
sourceEndLine: group[group.length - 1].number,
|
||||||
|
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
|
||||||
|
status: 'original',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResults(items: PreviewItem[]): ResultItem[] {
|
||||||
|
return items.slice(0, 12).map((item, index) => {
|
||||||
|
const [firstLine = '', ...rest] = item.editedContent.split('\n')
|
||||||
|
const output = rest.join('\n').trim() || item.editedContent.trim()
|
||||||
|
const instruction = firstLine.replace(/^问[::]\s*/, '').trim() || `数据条目 ${index + 1}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `result-${index + 1}`,
|
||||||
|
instruction,
|
||||||
|
input: '',
|
||||||
|
output,
|
||||||
|
originalInstruction: instruction,
|
||||||
|
originalInput: '',
|
||||||
|
originalOutput: output,
|
||||||
|
status: 'valid',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
41
frontend/src/views/data-process/create/types.ts
Normal file
41
frontend/src/views/data-process/create/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type ProcessType = 'structured' | 'unstructured'
|
||||||
|
|
||||||
|
export type StepId = 'create' | 'preview' | 'generate' | 'results'
|
||||||
|
|
||||||
|
export interface SourceLine {
|
||||||
|
number: number
|
||||||
|
content: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewItem {
|
||||||
|
id: string
|
||||||
|
sourceFileId: string
|
||||||
|
originalContent: string
|
||||||
|
editedContent: string
|
||||||
|
sourceStart: number | null
|
||||||
|
sourceEnd: number | null
|
||||||
|
sourceStartLine: number | null
|
||||||
|
sourceEndLine: number | null
|
||||||
|
tokenCount: number
|
||||||
|
status: 'original' | 'modified' | 'manual' | 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationState {
|
||||||
|
status: 'idle' | 'running' | 'success' | 'failed'
|
||||||
|
progress: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultItem {
|
||||||
|
id: string
|
||||||
|
instruction: string
|
||||||
|
input: string
|
||||||
|
output: string
|
||||||
|
originalInstruction: string
|
||||||
|
originalInput: string
|
||||||
|
originalOutput: string
|
||||||
|
status: 'valid' | 'modified' | 'invalid'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
335
frontend/src/views/dataset/DatasetCreateView.vue
Normal file
335
frontend/src/views/dataset/DatasetCreateView.vue
Normal 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>
|
||||||
168
frontend/src/views/dataset/DatasetListView.vue
Normal file
168
frontend/src/views/dataset/DatasetListView.vue
Normal 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>
|
||||||
157
frontend/src/views/dataset/DatasetPreviewView.vue
Normal file
157
frontend/src/views/dataset/DatasetPreviewView.vue
Normal 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>
|
||||||
290
frontend/src/views/eval/DimensionCreateView.vue
Normal file
290
frontend/src/views/eval/DimensionCreateView.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { MdEditor } from 'md-editor-v3'
|
||||||
|
import 'md-editor-v3/lib/style.css'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import {
|
||||||
|
createDimension,
|
||||||
|
updateDimension,
|
||||||
|
getDimension,
|
||||||
|
} from '@/api/modules/eval'
|
||||||
|
import { getModelList } from '@/api/modules/model'
|
||||||
|
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
|
||||||
|
import { DIMENSION_TYPE_MAP } from '@/constants'
|
||||||
|
import type { DimensionType, ModelItem } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
const editId = computed(() => route.params.id as string | undefined)
|
||||||
|
|
||||||
|
const evalModels = ref<ModelItem[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
type: '' as DimensionType | '',
|
||||||
|
description: '',
|
||||||
|
eval_model: '',
|
||||||
|
eval_method: '' as string | string[],
|
||||||
|
eval_prompt: '',
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
// text_similarity
|
||||||
|
bleu_n: 1,
|
||||||
|
output_precision: 3,
|
||||||
|
// metric
|
||||||
|
score_min: 0,
|
||||||
|
score_max: 5,
|
||||||
|
pass_threshold: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入维度名称', trigger: 'blur' },
|
||||||
|
{ max: 50, message: '不超过 50 字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
type: [{ required: true, message: '请选择指标类型', trigger: 'change' }],
|
||||||
|
eval_model: [{ required: true, message: '请选择大模型', trigger: 'change' }],
|
||||||
|
eval_prompt: [{ required: true, message: '请填写评估 Prompt', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = Object.entries(DIMENSION_TYPE_MAP).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
/** 当前类型下的评估方法选项 */
|
||||||
|
const currentMethods = computed(() => (form.type ? EVAL_METHODS[form.type] || [] : []))
|
||||||
|
|
||||||
|
/** 是否文本相似度类型(多选评估方法) */
|
||||||
|
const isTextSimilarity = computed(() => form.type === 'text_similarity')
|
||||||
|
|
||||||
|
/** 切换指标类型时重置相关字段 */
|
||||||
|
watch(
|
||||||
|
() => form.type,
|
||||||
|
(t) => {
|
||||||
|
if (!t) return
|
||||||
|
const methods = EVAL_METHODS[t] || []
|
||||||
|
const first = methods[0]?.value || ''
|
||||||
|
form.eval_method = isTextSimilarity.value ? [first] : first
|
||||||
|
// 切换 Prompt
|
||||||
|
if (form.type !== 'text_similarity' && EVAL_METHOD_PROMPTS[first]) {
|
||||||
|
form.eval_prompt = EVAL_METHOD_PROMPTS[first]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 评估方法变化时更新 Prompt */
|
||||||
|
watch(
|
||||||
|
() => form.eval_method,
|
||||||
|
(m) => {
|
||||||
|
if (form.type === 'text_similarity' || !m) return
|
||||||
|
const method = Array.isArray(m) ? m[0] : m
|
||||||
|
if (method && EVAL_METHOD_PROMPTS[method]) {
|
||||||
|
form.eval_prompt = EVAL_METHOD_PROMPTS[method]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadEditData() {
|
||||||
|
if (!editId.value) return
|
||||||
|
try {
|
||||||
|
const dim: any = await getDimension(editId.value)
|
||||||
|
// 补全所有字段回填(修复原项目回填不完整 bug)
|
||||||
|
Object.assign(form, {
|
||||||
|
name: dim.name || '',
|
||||||
|
type: dim.type || '',
|
||||||
|
description: dim.description || '',
|
||||||
|
eval_model: dim.eval_model || '',
|
||||||
|
eval_method: dim.eval_method || (dim.type ? EVAL_METHODS[dim.type]?.[0]?.value : ''),
|
||||||
|
eval_prompt: dim.eval_prompt || '',
|
||||||
|
is_active: dim.is_active !== false,
|
||||||
|
is_default: !!dim.is_default,
|
||||||
|
bleu_n: dim.bleu_n ?? 1,
|
||||||
|
output_precision: dim.output_precision ?? 3,
|
||||||
|
score_min: dim.score_min ?? 0,
|
||||||
|
score_max: dim.score_max ?? 5,
|
||||||
|
pass_threshold: dim.pass_threshold ?? 3,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const all = (await getModelList()) || []
|
||||||
|
evalModels.value = all.filter((m) => m.purpose === 'evaluation')
|
||||||
|
} catch {
|
||||||
|
evalModels.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data: any = {
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
description: form.description,
|
||||||
|
eval_model: form.type === 'text_similarity' ? null : form.eval_model,
|
||||||
|
eval_method: form.eval_method,
|
||||||
|
eval_prompt: form.type === 'text_similarity' ? null : form.eval_prompt,
|
||||||
|
is_active: form.is_active,
|
||||||
|
is_default: form.is_default,
|
||||||
|
create_time: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (form.type === 'text_similarity') {
|
||||||
|
data.bleu_n = form.bleu_n
|
||||||
|
data.output_precision = form.output_precision
|
||||||
|
}
|
||||||
|
if (form.type === 'metric') {
|
||||||
|
data.score_min = form.score_min
|
||||||
|
data.score_max = form.score_max
|
||||||
|
data.pass_threshold = form.pass_threshold
|
||||||
|
}
|
||||||
|
if (isEdit.value && editId.value) {
|
||||||
|
await updateDimension(editId.value, data)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createDimension(data)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
router.push('/model-eval')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadModels()
|
||||||
|
loadEditData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard :title="isEdit ? '编辑评测维度' : '添加评测维度'">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" style="max-width: 760px">
|
||||||
|
<el-form-item label="维度名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入维度名称" maxlength="50" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="指标类型" prop="type">
|
||||||
|
<el-select v-model="form.type" placeholder="请选择指标类型" style="width: 100%">
|
||||||
|
<el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 分类型 / 指标型:需要选择大模型和评估方法 -->
|
||||||
|
<template v-if="form.type === 'classification' || form.type === 'metric'">
|
||||||
|
<el-form-item label="选择大模型" prop="eval_model">
|
||||||
|
<el-select v-model="form.eval_model" placeholder="请选择评测模型" filterable style="width: 100%">
|
||||||
|
<el-option v-for="m in evalModels" :key="m.id" :label="m.name" :value="m.name" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="评估方式" prop="eval_method">
|
||||||
|
<el-radio-group v-model="(form.eval_method as string)">
|
||||||
|
<el-radio v-for="m in currentMethods" :key="m.value" :value="m.value">
|
||||||
|
{{ m.name }}
|
||||||
|
<span class="method-desc">{{ m.desc }}</span>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="评估 Prompt" prop="eval_prompt">
|
||||||
|
<MdEditor v-model="form.eval_prompt" language="zh-CN" :toolbars-exclude="['github', 'save']" style="height: 320px" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 文本相似度:多选评估方法 + BLEU/精度配置 -->
|
||||||
|
<template v-if="form.type === 'text_similarity'">
|
||||||
|
<el-form-item label="评估方式">
|
||||||
|
<el-checkbox-group v-model="(form.eval_method as string[])">
|
||||||
|
<el-checkbox v-for="m in currentMethods" :key="m.value" :value="m.value">
|
||||||
|
{{ m.name }}
|
||||||
|
<span class="method-desc">{{ m.desc }}</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="BLEU n-gram">
|
||||||
|
<el-select v-model="form.bleu_n" style="width: 160px">
|
||||||
|
<el-option :value="1" label="1-gram" />
|
||||||
|
<el-option :value="2" label="2-gram" />
|
||||||
|
<el-option :value="3" label="3-gram" />
|
||||||
|
<el-option :value="4" label="4-gram" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="输出精度">
|
||||||
|
<el-select v-model="form.output_precision" style="width: 160px">
|
||||||
|
<el-option :value="1" label="1 位小数" />
|
||||||
|
<el-option :value="2" label="2 位小数" />
|
||||||
|
<el-option :value="3" label="3 位小数" />
|
||||||
|
<el-option :value="4" label="4 位小数" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 指标型:评分范围 + 通过阈值 -->
|
||||||
|
<template v-if="form.type === 'metric'">
|
||||||
|
<el-form-item label="评分最小值">
|
||||||
|
<el-input-number v-model="form.score_min" :min="0" :step="1" style="width: 160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="评分最大值">
|
||||||
|
<el-input-number v-model="form.score_max" :min="0" :step="1" style="width: 160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="通过阈值">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<el-slider
|
||||||
|
v-model="form.pass_threshold"
|
||||||
|
:min="form.score_min"
|
||||||
|
:max="form.score_max"
|
||||||
|
:step="0.5"
|
||||||
|
show-input
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="启用该维度">
|
||||||
|
<el-switch v-model="form.is_active" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设为默认">
|
||||||
|
<el-switch v-model="form.is_default" />
|
||||||
|
</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>
|
||||||
|
.method-desc {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
177
frontend/src/views/eval/EvalCreateView.vue
Normal file
177
frontend/src/views/eval/EvalCreateView.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { startEval } from '@/api/modules/eval'
|
||||||
|
import { getDimensionList } from '@/api/modules/eval'
|
||||||
|
import { getTrainedModels } from '@/api/modules/model'
|
||||||
|
import { getDatasetList } from '@/api/modules/dataset'
|
||||||
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import type { Dimension, TrainedModel, DatasetItem, GpuInfo } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const trainedModels = ref<TrainedModel[]>([])
|
||||||
|
const evalDatasets = ref<DatasetItem[]>([])
|
||||||
|
const dimensions = ref<Dimension[]>([])
|
||||||
|
const gpus = ref<GpuInfo[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
eval_task_name: '',
|
||||||
|
eval_type: 'custom' as 'custom' | 'baseline',
|
||||||
|
model_id: '' as string | number,
|
||||||
|
gpu_id: '',
|
||||||
|
data_source: 'dataset' as 'dataset' | 'inference',
|
||||||
|
dataset_id: '' as string | number,
|
||||||
|
dimension_id: '' as string | number,
|
||||||
|
leaderboard: false, // 修复原项目字段名 bug(原读 leader 恒 false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
eval_task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
|
||||||
|
model_id: [{ required: true, message: '请选择评测模型', trigger: 'change' }],
|
||||||
|
gpu_id: [{ required: true, message: '请选择 GPU', trigger: 'change' }],
|
||||||
|
dimension_id: [{ required: true, message: '请选择评测维度', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEvalRules = computed(() => form.eval_type === 'custom')
|
||||||
|
const showDataset = computed(() => form.data_source === 'dataset')
|
||||||
|
|
||||||
|
/** 选中维度详情 */
|
||||||
|
const selectedDimension = computed(() =>
|
||||||
|
dimensions.value.find((d) => d.id == form.dimension_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [models, dims, allDs, sys] = await Promise.all([
|
||||||
|
getTrainedModels(),
|
||||||
|
getDimensionList(),
|
||||||
|
getDatasetList(),
|
||||||
|
getSystemInfo(),
|
||||||
|
])
|
||||||
|
trainedModels.value = models?.models || []
|
||||||
|
dimensions.value = dims || []
|
||||||
|
evalDatasets.value = (allDs || []).filter((d: DatasetItem) => d.type === 'eval')
|
||||||
|
gpus.value = sys?.gpu || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await startEval({
|
||||||
|
eval_task_name: form.eval_task_name,
|
||||||
|
eval_type: form.eval_type,
|
||||||
|
model_id: form.model_id,
|
||||||
|
gpu_id: form.gpu_id,
|
||||||
|
dataset_id: form.dataset_id,
|
||||||
|
dimension_id: form.dimension_id,
|
||||||
|
data_source: form.data_source,
|
||||||
|
leaderboard: form.leaderboard,
|
||||||
|
})
|
||||||
|
ElMessage.success('评测任务已启动')
|
||||||
|
router.push('/model-eval')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="新建评测">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" style="max-width: 720px">
|
||||||
|
<el-form-item label="任务名称" prop="eval_task_name">
|
||||||
|
<el-input v-model="form.eval_task_name" placeholder="请输入任务名称" maxlength="50" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="评测方式">
|
||||||
|
<el-radio-group v-model="form.eval_type">
|
||||||
|
<el-radio-button value="custom">自定义评测</el-radio-button>
|
||||||
|
<el-radio-button value="baseline">基线评测</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="评测模型" prop="model_id">
|
||||||
|
<el-select v-model="form.model_id" placeholder="请选择已训练模型" filterable style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="m in trainedModels"
|
||||||
|
:key="m.id"
|
||||||
|
:label="m.name"
|
||||||
|
:value="m.id"
|
||||||
|
:disabled="!m.merged"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="选择 GPU" prop="gpu_id">
|
||||||
|
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%">
|
||||||
|
<el-option v-for="(g, idx) in gpus" :key="idx" :label="`${g.name} (GPU ${idx})`" :value="idx" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="数据来源">
|
||||||
|
<el-radio-group v-model="form.data_source">
|
||||||
|
<el-radio value="dataset">评测数据集</el-radio>
|
||||||
|
<el-radio value="inference">推理结果集</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="showDataset" label="评测数据集" prop="dataset_id">
|
||||||
|
<el-select v-model="form.dataset_id" placeholder="请选择评测数据集" filterable style="width: 100%">
|
||||||
|
<el-option v-for="d in evalDatasets" :key="d.id" :label="d.name" :value="d.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 评测规则(仅 custom 显示) -->
|
||||||
|
<template v-if="showEvalRules">
|
||||||
|
<el-divider content-position="left">评测规则</el-divider>
|
||||||
|
<el-form-item label="评测维度" prop="dimension_id">
|
||||||
|
<el-select
|
||||||
|
v-model="form.dimension_id"
|
||||||
|
placeholder="请选择评测维度"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="d in dimensions" :key="d.id" :label="d.name" :value="d.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="selectedDimension" label="维度详情">
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="评估模型">{{ selectedDimension.eval_model || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="评估方法">
|
||||||
|
{{ Array.isArray(selectedDimension.eval_method) ? selectedDimension.eval_method.join(', ') : selectedDimension.eval_method || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="加入排行榜">
|
||||||
|
<el-switch v-model="form.leaderboard" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">启动评测</el-button>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
307
frontend/src/views/eval/EvalView.vue
Normal file
307
frontend/src/views/eval/EvalView.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import {
|
||||||
|
getEvalList,
|
||||||
|
deleteEval,
|
||||||
|
getDimensionList,
|
||||||
|
deleteDimension,
|
||||||
|
} from '@/api/modules/eval'
|
||||||
|
import { DIMENSION_TYPE_MAP } from '@/constants'
|
||||||
|
import type { EvalTask, Dimension } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const activeTab = ref('tasks')
|
||||||
|
|
||||||
|
const evalLoading = ref(false)
|
||||||
|
const evalList = ref<EvalTask[]>([])
|
||||||
|
const dimLoading = ref(false)
|
||||||
|
const dimensionList = ref<Dimension[]>([])
|
||||||
|
|
||||||
|
// 排行榜(原项目为 mock 数据)
|
||||||
|
const leaderboard = ref([
|
||||||
|
{ rank: 1, name: 'GPT-4', score: 92.5 },
|
||||||
|
{ rank: 2, name: 'Claude-3', score: 90.1 },
|
||||||
|
{ rank: 3, name: 'Qwen-Max', score: 85.3 },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function loadEvalList() {
|
||||||
|
evalLoading.value = true
|
||||||
|
try {
|
||||||
|
evalList.value = (await getEvalList()) || []
|
||||||
|
} catch {
|
||||||
|
evalList.value = []
|
||||||
|
} finally {
|
||||||
|
evalLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDimensions() {
|
||||||
|
dimLoading.value = true
|
||||||
|
try {
|
||||||
|
dimensionList.value = (await getDimensionList()) || []
|
||||||
|
} catch {
|
||||||
|
dimensionList.value = []
|
||||||
|
} finally {
|
||||||
|
dimLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEval(row: any) {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个评测任务吗?', '确认删除', { type: 'warning' })
|
||||||
|
await deleteEval(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadEvalList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDimension(row: any) {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个评测维度吗?', '确认删除', { type: 'warning' })
|
||||||
|
await deleteDimension(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDimension(row: any) {
|
||||||
|
router.push(`/model-eval/dimension/${row.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateClick() {
|
||||||
|
if (activeTab.value === 'tasks') {
|
||||||
|
router.push('/model-eval/create')
|
||||||
|
} else if (activeTab.value === 'dimensions') {
|
||||||
|
router.push('/model-eval/dimension/create')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
if (activeTab.value === 'tasks') {
|
||||||
|
loadEvalList()
|
||||||
|
} else if (activeTab.value === 'dimensions') {
|
||||||
|
loadDimensions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadEvalList()
|
||||||
|
loadDimensions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="eval-page">
|
||||||
|
<!-- 评测任务 -->
|
||||||
|
<DataTablePage
|
||||||
|
v-if="activeTab === 'tasks'"
|
||||||
|
title=""
|
||||||
|
:data="evalList"
|
||||||
|
:loading="evalLoading"
|
||||||
|
:delete-fn="handleDeleteEval"
|
||||||
|
create-text="创建评测任务"
|
||||||
|
@create="handleCreateClick"
|
||||||
|
row-key="id"
|
||||||
|
@refresh="loadEvalList"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<div class="eval-header-actions">
|
||||||
|
<el-link class="action-guide" :underlined="false">
|
||||||
|
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
|
||||||
|
</el-link>
|
||||||
|
<button class="action-refresh" @click="handleRefresh" title="刷新">
|
||||||
|
<i class="fa fa-refresh" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="任务名称" prop="eval_task_name" align="center" />
|
||||||
|
<el-table-column label="评测模型" prop="model_name" align="center" />
|
||||||
|
<el-table-column label="数据集" prop="dataset" align="center" />
|
||||||
|
<el-table-column label="指标" prop="metric" align="center" />
|
||||||
|
<el-table-column label="评分" prop="score" width="100" align="center" />
|
||||||
|
<el-table-column label="状态" prop="status" width="100" align="center" />
|
||||||
|
<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 }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDeleteEval(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
|
||||||
|
<!-- 排行榜 -->
|
||||||
|
<DataTablePage
|
||||||
|
v-if="activeTab === 'leaderboard'"
|
||||||
|
title=""
|
||||||
|
:data="leaderboard"
|
||||||
|
row-key="rank"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<div class="eval-header-actions">
|
||||||
|
<el-link class="action-guide" :underlined="false">
|
||||||
|
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
|
||||||
|
</el-link>
|
||||||
|
<button class="action-refresh" @click="handleRefresh" title="刷新">
|
||||||
|
<i class="fa fa-refresh" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="排名" prop="rank" width="80" align="center" />
|
||||||
|
<el-table-column label="模型名称" prop="name" align="center" />
|
||||||
|
<el-table-column label="综合评分" prop="score" align="center" />
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
|
||||||
|
<!-- 评测维度 -->
|
||||||
|
<DataTablePage
|
||||||
|
v-if="activeTab === 'dimensions'"
|
||||||
|
title=""
|
||||||
|
:data="dimensionList"
|
||||||
|
:loading="dimLoading"
|
||||||
|
:delete-fn="handleDeleteDimension"
|
||||||
|
create-text="添加维度"
|
||||||
|
@create="handleCreateClick"
|
||||||
|
row-key="id"
|
||||||
|
@refresh="loadDimensions"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button>
|
||||||
|
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<div class="eval-header-actions">
|
||||||
|
<el-link class="action-guide" :underlined="false">
|
||||||
|
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
|
||||||
|
</el-link>
|
||||||
|
<button class="action-refresh" @click="handleRefresh" title="刷新">
|
||||||
|
<i class="fa fa-refresh" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="维度名称" prop="name" align="center" />
|
||||||
|
<el-table-column label="类型" align="center" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ DIMENSION_TYPE_MAP[row.type] || row.type }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="描述" prop="description" show-overflow-tooltip align="center" />
|
||||||
|
<el-table-column label="状态" align="center" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_active" type="success" size="small">启用</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">禁用</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="editDimension(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDeleteDimension(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.eval-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 胶囊切换栏样式 */
|
||||||
|
.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; /* Primary color */
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eval-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.action-guide {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-refresh {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
733
frontend/src/views/fine-tune/FineTuneCreateView.vue
Normal file
733
frontend/src/views/fine-tune/FineTuneCreateView.vue
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import ModelSelectDialog from '@/components/ModelSelectDialog.vue'
|
||||||
|
import {
|
||||||
|
createFineTune,
|
||||||
|
startFineTune,
|
||||||
|
updateFineTune,
|
||||||
|
checkFineTuneName,
|
||||||
|
} from '@/api/modules/fineTune'
|
||||||
|
import { getModelList } from '@/api/modules/model'
|
||||||
|
import { getDatasetList } from '@/api/modules/dataset'
|
||||||
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS } from '@/constants'
|
||||||
|
import type { ModelItem, DatasetItem, GpuInfo } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const models = ref<ModelItem[]>([])
|
||||||
|
const datasets = ref<DatasetItem[]>([])
|
||||||
|
const gpus = ref<GpuInfo[]>([])
|
||||||
|
const selectedGpus = ref<number[]>([])
|
||||||
|
const modelDialogVisible = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
train_type: 'SFT' as 'SFT' | 'DPO' | 'CPT',
|
||||||
|
base_model: '' as string | number,
|
||||||
|
template: 'qwen',
|
||||||
|
train_method: 'lora' as 'lora' | 'full',
|
||||||
|
train_dataset_id: '' as string | number,
|
||||||
|
// 训练参数
|
||||||
|
batch_size: 1,
|
||||||
|
learning_rate: 0.0001,
|
||||||
|
n_epochs: 1,
|
||||||
|
save_steps: 100,
|
||||||
|
lr_scheduler_type: 'cosine',
|
||||||
|
max_length: 512,
|
||||||
|
warmup_ratio: 0.05,
|
||||||
|
weight_decay: 0.01,
|
||||||
|
// LoRA 参数
|
||||||
|
lora_alpha: 16, // 修复原项目 lora_alpha 默认值不一致 bug
|
||||||
|
lora_dropout: 0.1,
|
||||||
|
lora_rank: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入任务名称', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
pattern: /^[a-zA-Z0-9_]+$/,
|
||||||
|
message: '仅支持字母、数字、下划线',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
{ max: 50, message: '不超过 50 字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
base_model: [{ required: true, message: '请选择模型', trigger: 'change' }],
|
||||||
|
template: [{ required: true, message: '请选择训练模板', trigger: 'change' }],
|
||||||
|
train_dataset_id: [{ required: true, message: '请选择训练数据集', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLoraParams = computed(() => form.train_method === 'lora')
|
||||||
|
|
||||||
|
const selectedModel = computed(() => models.value.find((model) => model.id === form.base_model))
|
||||||
|
|
||||||
|
const modelDialogTitle = computed(() => selectedModel.value?.name || '')
|
||||||
|
|
||||||
|
/** 训练命令实时预览 */
|
||||||
|
const commandPreview = computed(() => {
|
||||||
|
const gpuIds = selectedGpus.value.length ? selectedGpus.value.join(',') : '0'
|
||||||
|
let cmd = `CUDA_VISIBLE_DEVICES=${gpuIds} llamafactory-cli train \\\n`
|
||||||
|
cmd += ` --stage ${form.train_type === 'DPO' ? 'dpo' : form.train_type === 'CPT' ? 'cpt' : 'sft'} \\\n`
|
||||||
|
cmd += ` --do_train \\\n`
|
||||||
|
cmd += ` --model_name_or_path <base_model_path> \\\n`
|
||||||
|
cmd += ` --dataset <dataset> \\\n`
|
||||||
|
cmd += ` --template ${form.template} \\\n`
|
||||||
|
cmd += ` --finetuning_type ${form.train_method} \\\n`
|
||||||
|
cmd += ` --output_dir ./saves/${form.name || 'output'} \\\n`
|
||||||
|
cmd += ` --per_device_train_batch_size ${form.batch_size} \\\n`
|
||||||
|
cmd += ` --learning_rate ${form.learning_rate} \\\n`
|
||||||
|
cmd += ` --num_train_epochs ${form.n_epochs} \\\n`
|
||||||
|
cmd += ` --save_steps ${form.save_steps} \\\n`
|
||||||
|
cmd += ` --lr_scheduler_type ${form.lr_scheduler_type} \\\n`
|
||||||
|
cmd += ` --cutoff_len ${form.max_length} \\\n`
|
||||||
|
cmd += ` --warmup_ratio ${form.warmup_ratio} \\\n`
|
||||||
|
cmd += ` --weight_decay ${form.weight_decay}`
|
||||||
|
if (showLoraParams.value) {
|
||||||
|
cmd += ` \\\n --lora_alpha ${form.lora_alpha}`
|
||||||
|
cmd += ` \\\n --lora_dropout ${form.lora_dropout}`
|
||||||
|
cmd += ` \\\n --lora_rank ${form.lora_rank}`
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
})
|
||||||
|
|
||||||
|
/** GPU 多选切换 */
|
||||||
|
function toggleGpu(index: number) {
|
||||||
|
const idx = selectedGpus.value.indexOf(index)
|
||||||
|
if (idx === -1) selectedGpus.value.push(index)
|
||||||
|
else selectedGpus.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpuUsageWidth(percent: number) {
|
||||||
|
return `${Math.max(0, Math.min(percent, 100))}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModelDialog() {
|
||||||
|
modelDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型选择弹窗确认 */
|
||||||
|
function handleModelConfirm(modelId: string | number) {
|
||||||
|
form.base_model = modelId
|
||||||
|
formRef.value?.validateField('base_model')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetParams() {
|
||||||
|
Object.assign(form, {
|
||||||
|
batch_size: 1,
|
||||||
|
learning_rate: 0.0001,
|
||||||
|
n_epochs: 1,
|
||||||
|
save_steps: 100,
|
||||||
|
lr_scheduler_type: 'cosine',
|
||||||
|
max_length: 512,
|
||||||
|
warmup_ratio: 0.05,
|
||||||
|
weight_decay: 0.01,
|
||||||
|
lora_alpha: 16,
|
||||||
|
lora_dropout: 0.1,
|
||||||
|
lora_rank: 8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParamsExpanded = ref(false)
|
||||||
|
|
||||||
|
const allParams = computed(() => {
|
||||||
|
const params = [
|
||||||
|
{ key: 'batch_size', name: 'batch_size', desc: '批次大小,代表模型训练过程中,模型更新一次参数所需要的数据样本数。', hint: '[1, 64], step:1', type: 'number', min: 1, max: 64, step: 1 },
|
||||||
|
{ key: 'learning_rate', name: 'learning_rate', desc: '学习率,代表每次更新数据的增量参数权重比例。', hint: '[0.000001, 1]', type: 'number', min: 0.000001, max: 1, step: 0.00001, precision: 6 },
|
||||||
|
{ key: 'n_epochs', name: 'n_epochs', desc: '循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是 1-3 遍即可,可依据需求进行调整', hint: '[1, 100], step:1', type: 'number', min: 1, max: 100, step: 1 },
|
||||||
|
{ key: 'save_steps', name: 'save_steps', desc: '保存步数,训练阶段模型保存的间隔步长。', hint: '[10, 10000]', type: 'number', min: 10, max: 10000, step: 1 },
|
||||||
|
{ key: 'lr_scheduler_type', name: 'lr_scheduler_type', desc: '学习率调整策略,选择不同的学习率策略。', hint: '', type: 'select', options: LR_SCHEDULER_OPTIONS },
|
||||||
|
{ key: 'max_length', name: 'max_length', desc: '序列长度,单个训练数据样本的最大长度。', hint: '[64, 4096]', type: 'number', min: 64, max: 4096, step: 1 },
|
||||||
|
{ key: 'warmup_ratio', name: 'warmup_ratio', desc: '学习率预热比例,学习率预热阶段占总训练步数的比例。', hint: '[0, 1]', type: 'number', min: 0, max: 1, step: 0.01, precision: 2 },
|
||||||
|
{ key: 'weight_decay', name: 'weight_decay', desc: '权重衰减,用于在优化过程中对模型参数施加惩罚,防止过拟合。', hint: '[0, 1]', type: 'number', min: 0, max: 1, step: 0.01, precision: 2 },
|
||||||
|
]
|
||||||
|
if (showLoraParams.value) {
|
||||||
|
params.push(
|
||||||
|
{ key: 'lora_alpha', name: 'lora_alpha', desc: 'LoRA 缩放系数。', hint: '16/32/64/128', type: 'select', options: [{label:'16',value:16},{label:'32',value:32},{label:'64',value:64},{label:'128',value:128}] },
|
||||||
|
{ key: 'lora_rank', name: 'lora_rank', desc: 'LoRA 秩大小,控制低秩矩阵的维度。', hint: '8/16/32/64', type: 'select', options: [{label:'8',value:8},{label:'16',value:16},{label:'32',value:32},{label:'64',value:64}] },
|
||||||
|
{ key: 'lora_dropout', name: 'lora_dropout', desc: 'LoRA 层的 dropout 比例。', hint: '[0, 1]', type: 'number', min: 0, max: 1, step: 0.05, precision: 2 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleParams = computed(() => {
|
||||||
|
return isParamsExpanded.value ? allParams.value : allParams.value.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
models.value = (await getModelList()) || []
|
||||||
|
} catch {
|
||||||
|
models.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDatasets() {
|
||||||
|
try {
|
||||||
|
datasets.value = (await getDatasetList()) || []
|
||||||
|
} catch {
|
||||||
|
datasets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGpus() {
|
||||||
|
try {
|
||||||
|
const sys = await getSystemInfo()
|
||||||
|
gpus.value = sys?.gpu || []
|
||||||
|
// 默认选中第一个
|
||||||
|
if (gpus.value.length > 0) selectedGpus.value = [0]
|
||||||
|
} catch {
|
||||||
|
gpus.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
if (selectedGpus.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个 GPU')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
// 任务名查重
|
||||||
|
const check = await checkFineTuneName(form.name).catch(() => ({ exists: false }))
|
||||||
|
if ((check as any).exists) {
|
||||||
|
ElMessage.error('任务名称已存在,请更换')
|
||||||
|
submitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一步:创建任务记录
|
||||||
|
const taskData = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
base_model: form.base_model,
|
||||||
|
template: form.template,
|
||||||
|
train_type: form.train_type,
|
||||||
|
train_method: form.train_method,
|
||||||
|
gpus: selectedGpus.value,
|
||||||
|
train_dataset_id: form.train_dataset_id,
|
||||||
|
output_model_name: form.name,
|
||||||
|
batch_size: form.batch_size,
|
||||||
|
learning_rate: form.learning_rate,
|
||||||
|
n_epochs: form.n_epochs,
|
||||||
|
save_steps: form.save_steps,
|
||||||
|
lr_scheduler_type: form.lr_scheduler_type,
|
||||||
|
max_length: form.max_length,
|
||||||
|
warmup_ratio: form.warmup_ratio,
|
||||||
|
weight_decay: form.weight_decay,
|
||||||
|
lora_alpha: form.lora_alpha,
|
||||||
|
lora_dropout: form.lora_dropout,
|
||||||
|
lora_rank: form.lora_rank,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
const createRes: any = await createFineTune(taskData)
|
||||||
|
const taskId = createRes?.id || createRes
|
||||||
|
|
||||||
|
// 第二步:启动训练
|
||||||
|
try {
|
||||||
|
await startFineTune({
|
||||||
|
task_id: taskId,
|
||||||
|
name: form.name,
|
||||||
|
base_model: form.base_model,
|
||||||
|
template: form.template,
|
||||||
|
train_type: form.train_type,
|
||||||
|
train_method: form.train_method,
|
||||||
|
train_dataset_id: form.train_dataset_id,
|
||||||
|
output_model_name: form.name,
|
||||||
|
gpus: selectedGpus.value,
|
||||||
|
batch_size: form.batch_size,
|
||||||
|
learning_rate: form.learning_rate,
|
||||||
|
n_epochs: form.n_epochs,
|
||||||
|
save_steps: form.save_steps,
|
||||||
|
lr_scheduler_type: form.lr_scheduler_type,
|
||||||
|
max_length: form.max_length,
|
||||||
|
warmup_ratio: form.warmup_ratio,
|
||||||
|
weight_decay: form.weight_decay,
|
||||||
|
lora_alpha: form.lora_alpha,
|
||||||
|
lora_dropout: form.lora_dropout,
|
||||||
|
lora_rank: form.lora_rank,
|
||||||
|
})
|
||||||
|
ElMessage.success('训练任务已创建并启动')
|
||||||
|
} catch (e) {
|
||||||
|
// 启动失败,回写状态
|
||||||
|
await updateFineTune(taskId, { status: 'failed' })
|
||||||
|
}
|
||||||
|
router.push('/fine-tune')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadModels()
|
||||||
|
loadDatasets()
|
||||||
|
loadGpus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fine-tune-create page-card-host has-fixed-footer">
|
||||||
|
<PageCard title="创建训练任务">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="left">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<el-divider content-position="left">基本信息</el-divider>
|
||||||
|
<el-form-item label="任务名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="字母、数字、下划线" maxlength="50" show-word-limit style="width: 420px;" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit style="width: 600px;" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 训练配置 -->
|
||||||
|
<el-divider content-position="left">训练配置</el-divider>
|
||||||
|
<el-form-item label="GPU 硬件">
|
||||||
|
<div class="gpu-list">
|
||||||
|
<div
|
||||||
|
v-for="(gpu, idx) in gpus"
|
||||||
|
:key="idx"
|
||||||
|
class="gpu-card"
|
||||||
|
:class="{ active: selectedGpus.includes(idx), 'is-busy': gpu.gpu_percent > 80 }"
|
||||||
|
@click="toggleGpu(idx)"
|
||||||
|
>
|
||||||
|
<div class="gpu-card-top">
|
||||||
|
<div class="gpu-title">
|
||||||
|
<span class="gpu-index">GPU-{{ idx }}</span>
|
||||||
|
<span class="gpu-name">{{ gpu.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="gpu-usage">{{ gpu.gpu_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="gpu-usage-bar">
|
||||||
|
<span :style="{ width: gpuUsageWidth(gpu.gpu_percent) }" />
|
||||||
|
</div>
|
||||||
|
<div class="gpu-meta">
|
||||||
|
<span>显存 {{ gpu.memory_used_gb }}/{{ gpu.memory_total_gb }}GB</span>
|
||||||
|
<span>{{ gpu.temperature }}°C</span>
|
||||||
|
<span>{{ gpu.power_w }}W</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!gpus.length" class="gpu-empty">暂无 GPU 信息</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="训练方式">
|
||||||
|
<el-radio-group v-model="form.train_type">
|
||||||
|
<el-radio-button value="SFT">SFT 微调训练</el-radio-button>
|
||||||
|
<el-radio-button value="DPO">DPO 偏好训练</el-radio-button>
|
||||||
|
<el-radio-button value="CPT">CPT 继续预训练</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="选择模型" prop="base_model">
|
||||||
|
<el-input
|
||||||
|
class="model-picker-input"
|
||||||
|
:model-value="modelDialogTitle"
|
||||||
|
placeholder="请选择基座模型"
|
||||||
|
readonly
|
||||||
|
@click="openModelDialog"
|
||||||
|
style="width: 420px;"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<i class="fa fa-angle-right" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="训练模板" prop="template">
|
||||||
|
<el-select v-model="form.template" placeholder="请选择训练模板" filterable style="width: 420px;">
|
||||||
|
<el-option-group v-for="group in TEMPLATE_GROUPS" :key="group.label" :label="group.label">
|
||||||
|
<el-option v-for="opt in group.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-option-group>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="训练方法">
|
||||||
|
<el-radio-group v-model="form.train_method">
|
||||||
|
<el-radio value="lora">LoRA(高效微调)</el-radio>
|
||||||
|
<el-radio value="full">全参微调</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 超参配置 -->
|
||||||
|
<el-divider content-position="left">
|
||||||
|
<span style="font-size: 15px; font-weight: 600; color: #1f2937; margin-right: 12px;">超参配置</span>
|
||||||
|
<el-button link type="primary" size="small" @click="resetParams">
|
||||||
|
<i class="fa fa-refresh" style="margin-right: 4px;" /> 恢复默认配置
|
||||||
|
</el-button>
|
||||||
|
</el-divider>
|
||||||
|
|
||||||
|
<div class="hyperparam-section">
|
||||||
|
<div class="param-table-wrapper">
|
||||||
|
<div class="param-table">
|
||||||
|
<div class="param-header">
|
||||||
|
<div class="param-col name">参数名称</div>
|
||||||
|
<div class="param-col config">配置</div>
|
||||||
|
<div class="param-col desc">说明</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-row" v-for="param in visibleParams" :key="param.key">
|
||||||
|
<div class="param-col name">{{ param.name }}</div>
|
||||||
|
<div class="param-col config">
|
||||||
|
<el-input-number
|
||||||
|
v-if="param.type === 'number'"
|
||||||
|
v-model="(form as any)[param.key]"
|
||||||
|
:min="param.min" :max="param.max" :step="param.step" :precision="param.precision"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-else-if="param.type === 'select'"
|
||||||
|
v-model="(form as any)[param.key]"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<el-option v-for="o in param.options" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
<span class="param-hint" v-if="param.hint">{{ param.hint }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-col desc">
|
||||||
|
<el-tooltip :content="param.desc" placement="top" effect="dark" :show-after="200">
|
||||||
|
<span class="desc-text">{{ param.desc }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-footer">
|
||||||
|
<el-button link type="primary" @click="isParamsExpanded = !isParamsExpanded">
|
||||||
|
<i :class="isParamsExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'" style="margin-right: 4px;" />
|
||||||
|
{{ isParamsExpanded ? '收起配置' : '展开配置' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据配置 -->
|
||||||
|
<el-divider content-position="left">数据配置</el-divider>
|
||||||
|
<el-form-item label="训练数据集" prop="train_dataset_id">
|
||||||
|
<el-select v-model="form.train_dataset_id" placeholder="请选择训练数据集" filterable style="width: 420px;">
|
||||||
|
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 训练命令预览 -->
|
||||||
|
<el-divider content-position="left">训练命令预览</el-divider>
|
||||||
|
<div class="command-preview-wrapper">
|
||||||
|
<pre class="command-preview">{{ commandPreview }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions-wrapper">
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">创建并启动训练</el-button>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
|
||||||
|
<ModelSelectDialog
|
||||||
|
v-model="modelDialogVisible"
|
||||||
|
:models="models"
|
||||||
|
:current-model-id="form.base_model"
|
||||||
|
@confirm="handleModelConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.fine-tune-create {
|
||||||
|
:deep(.page-card) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-shadow: inset 0 0 0 1px #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-busy {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: #fffaf0;
|
||||||
|
|
||||||
|
.gpu-usage {
|
||||||
|
color: #b45309;
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-usage-bar span {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-busy.active {
|
||||||
|
border-color: #dc2626;
|
||||||
|
background: #fff7f7;
|
||||||
|
box-shadow: inset 0 0 0 1px #dc2626;
|
||||||
|
|
||||||
|
.gpu-usage {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-usage-bar span {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-index {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-usage {
|
||||||
|
height: 20px;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-usage-bar {
|
||||||
|
height: 3px;
|
||||||
|
margin: 7px 0 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-empty {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-preview-wrapper {
|
||||||
|
margin-left: 80px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 240px;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #eef0f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 112px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-preview {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-input {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 训练参数表格样式 */
|
||||||
|
.hyperparam-section {
|
||||||
|
margin: 32px 0 20px;
|
||||||
|
}
|
||||||
|
.hyperparam-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.param-config-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.param-config-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.param-table-wrapper {
|
||||||
|
margin-left: 80px;
|
||||||
|
border: 1px solid #eef0f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-left: 80px;
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 0; /* ensure it starts at 0 */
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-required:not(.is-no-asterisk) > .el-form-item__label::before {
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.param-header, .param-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 420px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.param-header {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #eef0f5;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
border-bottom: 1px solid #eef0f5;
|
||||||
|
}
|
||||||
|
.param-col {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.param-col.name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.param-col.config {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.param-col.desc {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.desc-text {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.param-hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.param-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.gpu-list {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
285
frontend/src/views/fine-tune/FineTuneListView.vue
Normal file
285
frontend/src/views/fine-tune/FineTuneListView.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||||
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
import {
|
||||||
|
getFineTuneList,
|
||||||
|
deleteFineTune,
|
||||||
|
stopFineTune,
|
||||||
|
getFineTuneProgress,
|
||||||
|
getFineTune,
|
||||||
|
} from '@/api/modules/fineTune'
|
||||||
|
import { TRAIN_TYPE_MAP, TRAIN_METHOD_MAP } from '@/constants'
|
||||||
|
import type { FineTuneTask, TrainingProgress } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataList = ref<FineTuneTask[]>([])
|
||||||
|
const progressCache = ref<Record<string, TrainingProgress>>({})
|
||||||
|
|
||||||
|
// ============ 列头筛选 ============
|
||||||
|
const trainTypeOptions = Object.entries(TRAIN_TYPE_MAP).map(([value, label]) => ({ value, label }))
|
||||||
|
const trainMethodOptions = Object.entries(TRAIN_METHOD_MAP).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
trainType: [] as string[],
|
||||||
|
trainMethod: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 应用筛选后的列表 */
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
return dataList.value.filter((row) => {
|
||||||
|
if (filters.value.trainType.length && !filters.value.trainType.includes(row.train_type)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.value.trainMethod.length && !filters.value.trainMethod.includes(row.train_method)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
dataList.value = (await getFineTuneList()) || []
|
||||||
|
// 列表加载完成后,立即获取一次运行中任务的进度
|
||||||
|
refreshProgress()
|
||||||
|
} catch {
|
||||||
|
// 拦截器已提示
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新训练进度(仅 running/pending 任务) */
|
||||||
|
async function refreshProgress() {
|
||||||
|
const activeTasks = dataList.value.filter(
|
||||||
|
(t) => t.status === 'running' || t.status === 'pending',
|
||||||
|
)
|
||||||
|
for (const task of activeTasks) {
|
||||||
|
try {
|
||||||
|
const [progress, status] = await Promise.all([
|
||||||
|
getFineTuneProgress(task.id),
|
||||||
|
getFineTune(task.id),
|
||||||
|
])
|
||||||
|
progressCache.value[task.id] = progress
|
||||||
|
// 状态变化时更新
|
||||||
|
if (status.status && status.status !== task.status) {
|
||||||
|
task.status = status.status
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: any) {
|
||||||
|
await deleteFineTune(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop(row: any) {
|
||||||
|
await stopFineTune(row.id)
|
||||||
|
ElMessage.success('训练任务已停止')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewLog(row: any) {
|
||||||
|
router.push(`/training-log/${row.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
modelsStore.load()
|
||||||
|
loadData()
|
||||||
|
progressTimer = setInterval(refreshProgress, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (progressTimer) clearInterval(progressTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DataTablePage
|
||||||
|
title="模型微调"
|
||||||
|
:data="filteredList"
|
||||||
|
:loading="loading"
|
||||||
|
searchable
|
||||||
|
:search-fields="['name']"
|
||||||
|
create-text="创建训练任务"
|
||||||
|
create-to="/fine-tune/create"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
@refresh="loadData"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column
|
||||||
|
label="任务名称"
|
||||||
|
prop="name"
|
||||||
|
align="center"
|
||||||
|
width="180"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="任务状态" align="center" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ModelStatusTag :status="row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" width="140">
|
||||||
|
<template #header>
|
||||||
|
<div class="filter-header">
|
||||||
|
<span>训练方式</span>
|
||||||
|
<el-popover trigger="click" placement="bottom" :width="160">
|
||||||
|
<template #reference>
|
||||||
|
<el-badge :is-dot="filters.trainType.length > 0" class="filter-badge">
|
||||||
|
<i
|
||||||
|
class="fa fa-filter filter-icon"
|
||||||
|
:class="{ active: filters.trainType.length > 0 }"
|
||||||
|
/>
|
||||||
|
</el-badge>
|
||||||
|
</template>
|
||||||
|
<el-checkbox-group v-model="filters.trainType" class="filter-options">
|
||||||
|
<el-checkbox v-for="opt in trainTypeOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<el-button size="small" link @click="filters.trainType = []">清除</el-button>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ TRAIN_TYPE_MAP[row.train_type] || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" width="120">
|
||||||
|
<template #header>
|
||||||
|
<div class="filter-header">
|
||||||
|
<span>训练方法</span>
|
||||||
|
<el-popover trigger="click" placement="bottom" :width="160">
|
||||||
|
<template #reference>
|
||||||
|
<el-badge :is-dot="filters.trainMethod.length > 0" class="filter-badge">
|
||||||
|
<i
|
||||||
|
class="fa fa-filter filter-icon"
|
||||||
|
:class="{ active: filters.trainMethod.length > 0 }"
|
||||||
|
/>
|
||||||
|
</el-badge>
|
||||||
|
</template>
|
||||||
|
<el-checkbox-group v-model="filters.trainMethod" class="filter-options">
|
||||||
|
<el-checkbox v-for="opt in trainMethodOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<el-button size="small" link @click="filters.trainMethod = []">清除</el-button>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ row.train_method || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="基座模型" align="center" width="190" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ modelsStore.getModelName(row.base_model) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="训练开始时间" align="center" width="190">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="训练时长" align="center" width="130">
|
||||||
|
<template #default="{ row }">{{ row.train_duration || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="进度"
|
||||||
|
align="center"
|
||||||
|
width="140"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="progress-value">
|
||||||
|
{{ progressCache[row.id]?.progress ?? row.progress ?? 0 }}%
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'running'"
|
||||||
|
type="warning"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleStop(row)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-stop-circle-o" style="margin-right: 4px" /> 停止
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" link size="small" @click="viewLog(row)">
|
||||||
|
<i class="fa fa-file-text-o" 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>
|
||||||
|
.progress-value {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列头筛选 */
|
||||||
|
.filter-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-badge {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon.active {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
695
frontend/src/views/inference/InferenceChatView.vue
Normal file
695
frontend/src/views/inference/InferenceChatView.vue
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import MarkdownView from '@/components/MarkdownView.vue'
|
||||||
|
import { useStreamChat } from '@/composables/useStreamChat'
|
||||||
|
import { getCompare } from '@/api/modules/compare'
|
||||||
|
import type { CompareTask, LoadedModel } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const taskId = route.params.id as string
|
||||||
|
/** 是否为 mock 直通模式(新建推理假数据进入,不走真实任务接口) */
|
||||||
|
const isMock = taskId === 'mock'
|
||||||
|
/** 当前对话使用的模型名 */
|
||||||
|
const modelName = ref(route.query.model as string || '')
|
||||||
|
|
||||||
|
const { message, loading, send, reset } = useStreamChat()
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
think?: string
|
||||||
|
isThinking?: boolean
|
||||||
|
isStreaming?: boolean
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ref<CompareTask | null>(null)
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputQuestion = ref('')
|
||||||
|
const systemPrompt = ref('')
|
||||||
|
const contentRef = ref<HTMLElement>()
|
||||||
|
/** 设置面板抽屉 */
|
||||||
|
const showSettings = ref(false)
|
||||||
|
|
||||||
|
/** 获取任务信息,定位已启动的模型(mock 模式跳过) */
|
||||||
|
async function loadTask() {
|
||||||
|
if (isMock) return
|
||||||
|
try {
|
||||||
|
task.value = await getCompare(taskId)
|
||||||
|
const models = parseLoadedModels(task.value)
|
||||||
|
if (models[0]?.model_name) modelName.value = models[0].model_name
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLoadedModels(t: CompareTask | null): LoadedModel[] {
|
||||||
|
if (!t?.load_status) return []
|
||||||
|
try {
|
||||||
|
const ls = typeof t.load_status === 'string' ? JSON.parse(t.load_status) : t.load_status
|
||||||
|
return ls.loaded_models || []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const question = inputQuestion.value.trim()
|
||||||
|
if (!question || loading.value) return
|
||||||
|
|
||||||
|
// 推入用户消息
|
||||||
|
messages.value.push({ role: 'user', content: question, done: true })
|
||||||
|
// 推入占位助手消息
|
||||||
|
const assistantMsg = reactive<ChatMessage>({
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
think: '',
|
||||||
|
isThinking: false,
|
||||||
|
isStreaming: true,
|
||||||
|
done: false,
|
||||||
|
})
|
||||||
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
inputQuestion.value = ''
|
||||||
|
await nextTick()
|
||||||
|
resetInputHeight()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
// mock 模式:直接用假数据逐字填充
|
||||||
|
if (isMock) {
|
||||||
|
await mockReply(assistantMsg, question)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实模式:获取已启动模型的端口/路径
|
||||||
|
const models = parseLoadedModels(task.value)
|
||||||
|
const target = models[0]
|
||||||
|
if (!target) {
|
||||||
|
ElMessage.error('未找到已启动的模型')
|
||||||
|
assistantMsg.content = '未找到已启动的模型,请先返回列表加载模型'
|
||||||
|
assistantMsg.done = true
|
||||||
|
assistantMsg.isStreaming = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听流式 message 变化,同步到 assistantMsg
|
||||||
|
const watchStop = watchMessage(assistantMsg)
|
||||||
|
|
||||||
|
await send({
|
||||||
|
port: target.port,
|
||||||
|
model_name: target.model_name,
|
||||||
|
model_path: '',
|
||||||
|
system_prompt: systemPrompt.value,
|
||||||
|
user_question: question,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 完成后同步最终内容
|
||||||
|
assistantMsg.content = message.value.displayContent || message.value.error || '(无回复)'
|
||||||
|
assistantMsg.think = message.value.thinkContent
|
||||||
|
assistantMsg.isThinking = false
|
||||||
|
assistantMsg.isStreaming = false
|
||||||
|
assistantMsg.done = true
|
||||||
|
watchStop()
|
||||||
|
reset()
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 模式:生成假回答并逐字填入消息(模拟流式效果)
|
||||||
|
*/
|
||||||
|
async function mockReply(assistantMsg: ChatMessage, question: string) {
|
||||||
|
const answer =
|
||||||
|
`你好!我是 **${modelName.value || '示例模型'}**(mock 演示)。\n\n` +
|
||||||
|
`你刚才问的是:\n\n> ${question}\n\n` +
|
||||||
|
`这是一段模拟回复,用于演示对话界面。接入真实模型后,这里会展示模型的真实推理输出。\n\n` +
|
||||||
|
`## 说明\n- 当前为前端 mock 环境\n- 回复内容由本地生成\n- 流式打字效果为前端模拟`
|
||||||
|
// 逐字填充,模拟流式
|
||||||
|
for (const ch of answer) {
|
||||||
|
assistantMsg.content += ch
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
// 每 3 个字符暂停一下,控制速度
|
||||||
|
if (assistantMsg.content.length % 3 === 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assistantMsg.isStreaming = false
|
||||||
|
assistantMsg.done = true
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询同步流式状态到展示消息 */
|
||||||
|
function watchMessage(assistantMsg: ChatMessage) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
assistantMsg.content = message.value.displayContent
|
||||||
|
assistantMsg.think = message.value.thinkContent
|
||||||
|
assistantMsg.isThinking = message.value.isThinking
|
||||||
|
if (message.value.done) clearInterval(timer)
|
||||||
|
scrollToBottom()
|
||||||
|
}, 80)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (contentRef.value) {
|
||||||
|
contentRef.value.scrollTop = contentRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewChat() {
|
||||||
|
messages.value = []
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入框自适应高度 */
|
||||||
|
function autoResize(e: Event) {
|
||||||
|
const el = e.target as HTMLTextAreaElement
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置输入框高度 */
|
||||||
|
function resetInputHeight() {
|
||||||
|
const el = document.querySelector('.input-box') as HTMLTextAreaElement
|
||||||
|
if (el) el.style.height = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTask)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-wrap">
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-title">
|
||||||
|
<span class="title-text">{{ modelName || '模型对话' }}</span>
|
||||||
|
<span v-if="isMock" class="mock-badge">mock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="header-btn" title="设置" @click="showSettings = true">
|
||||||
|
<i class="fa fa-sliders" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div ref="contentRef" class="chat-body">
|
||||||
|
<div class="chat-body-inner">
|
||||||
|
<div v-if="messages.length === 0" class="empty-hint">
|
||||||
|
<div class="empty-logo">
|
||||||
|
<i class="fa fa-cube" />
|
||||||
|
</div>
|
||||||
|
<h2>有什么我可以帮你的吗?</h2>
|
||||||
|
<p>开始与 {{ modelName || '模型' }} 对话</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(msg, idx) in messages"
|
||||||
|
:key="idx"
|
||||||
|
class="msg-row"
|
||||||
|
:class="msg.role"
|
||||||
|
>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div v-if="msg.role === 'assistant'" class="avatar assistant">
|
||||||
|
<i class="fa fa-robot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息内容 -->
|
||||||
|
<div class="bubble-wrap">
|
||||||
|
<!-- 思考过程(可折叠) -->
|
||||||
|
<el-collapse v-if="msg.think" class="think-collapse">
|
||||||
|
<el-collapse-item title="思考过程" name="think">
|
||||||
|
<div class="think-content">{{ msg.think }}</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
|
||||||
|
<div v-if="msg.role === 'user'" class="bubble user-bubble">
|
||||||
|
{{ msg.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="msg.done" class="bubble ai-bubble markdown">
|
||||||
|
<MarkdownView :content="msg.content" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bubble ai-bubble streaming">
|
||||||
|
<span>{{ msg.content || (msg.isThinking ? '思考中...' : '生成中') }}</span>
|
||||||
|
<span class="typing-cursor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入栏 -->
|
||||||
|
<footer class="chat-input-container">
|
||||||
|
<div class="chat-input-inner">
|
||||||
|
<button class="clear-btn" title="清空对话" @click="handleNewChat">
|
||||||
|
<i class="fa fa-eraser" />
|
||||||
|
</button>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
v-model="inputQuestion"
|
||||||
|
class="input-box"
|
||||||
|
rows="1"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="给模型发送消息..."
|
||||||
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
@input="autoResize"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="send-btn"
|
||||||
|
:class="{ active: inputQuestion.trim() && !loading }"
|
||||||
|
:disabled="!inputQuestion.trim() || loading"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<i class="fa fa-arrow-up" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-hint">内容由 AI 生成,请仔细甄别。</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- 设置抽屉(系统提示词等) -->
|
||||||
|
<el-drawer v-model="showSettings" title="对话设置" size="360px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="系统提示词">
|
||||||
|
<el-input
|
||||||
|
v-model="systemPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="设置模型角色/约束(可选)"
|
||||||
|
resize="none"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="danger" plain @click="handleNewChat" style="width: 100%">
|
||||||
|
<i class="fa fa-trash-o" style="margin-right: 4px" />清空当前对话
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.chat-wrap {
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 顶部栏 ============ */
|
||||||
|
.chat-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
|
||||||
|
.title-text {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b45309;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 消息列表 ============ */
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 0;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
/* 隐藏滚动条但保留功能 */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body-inner {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
margin-top: 10vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.empty-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
/* 用户消息:靠右排列 */
|
||||||
|
&.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI 消息:靠左排列 */
|
||||||
|
&.assistant {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像 */
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
&.assistant {
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 气泡容器 */
|
||||||
|
.bubble-wrap {
|
||||||
|
max-width: 85%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 气泡 */
|
||||||
|
.bubble {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble {
|
||||||
|
color: #111827;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
&.markdown {
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
:deep(.markdown-view p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb !important;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 思考过程折叠 */
|
||||||
|
.think-collapse {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__header) {
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
height: 36px;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__wrap) {
|
||||||
|
border-bottom: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-content {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border-top: 1px dashed #e5e7eb;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式打字光标 */
|
||||||
|
.streaming .typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #111827;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 输入栏 ============ */
|
||||||
|
.chat-input-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 20%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: #f4f4f5;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 4px 44px 4px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111827;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: not-allowed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #111827;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #374151;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
frontend/src/views/inference/InferenceCreateView.vue
Normal file
193
frontend/src/views/inference/InferenceCreateView.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { getModelList, getTrainedModels } from '@/api/modules/model'
|
||||||
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const startupStatus = ref('')
|
||||||
|
|
||||||
|
const dbModels = ref<ModelItem[]>([])
|
||||||
|
const trainedModels = ref<TrainedModel[]>([])
|
||||||
|
const gpus = ref<GpuInfo[]>([])
|
||||||
|
|
||||||
|
/** 可选模型(下拉用,区分本地/已训练两类) */
|
||||||
|
interface SelectableModel {
|
||||||
|
/** 下拉唯一值:db-{id} / trained-{id} */
|
||||||
|
key: string
|
||||||
|
id: string | number
|
||||||
|
name: string
|
||||||
|
source: 'database' | 'trained'
|
||||||
|
model_path: string
|
||||||
|
merged?: boolean
|
||||||
|
merging?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地模型选项 */
|
||||||
|
const dbOptions = computed<SelectableModel[]>(() =>
|
||||||
|
dbModels.value.map((m) => ({
|
||||||
|
key: `db-${m.id}`,
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
source: 'database',
|
||||||
|
model_path: m.path || '',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 已训练模型选项(未合并的禁用) */
|
||||||
|
const trainedOptions = computed<SelectableModel[]>(() =>
|
||||||
|
trainedModels.value.map((m) => ({
|
||||||
|
key: `trained-${m.id}`,
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
source: 'trained',
|
||||||
|
model_path: m.merged_path || m.base_model_path || '',
|
||||||
|
merged: m.merged,
|
||||||
|
merging: m.merging,
|
||||||
|
disabled: m.merged === false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** key → 模型映射,便于取选中项 */
|
||||||
|
const modelMap = computed<Record<string, SelectableModel>>(() => {
|
||||||
|
const map: Record<string, SelectableModel> = {}
|
||||||
|
for (const m of [...dbOptions.value, ...trainedOptions.value]) map[m.key] = m
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
/** 选中的模型 key(单选) */
|
||||||
|
model_key: '',
|
||||||
|
/** 使用的 GPU */
|
||||||
|
gpu_id: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [{ required: true, message: '请输入推理名称', trigger: 'blur' }],
|
||||||
|
model_key: [{ required: true, message: '请选择模型', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前选中的模型对象 */
|
||||||
|
const selectedModel = computed(() => modelMap.value[form.model_key])
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
const m = selectedModel.value
|
||||||
|
if (!m) {
|
||||||
|
ElMessage.warning('请选择模型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
startupStatus.value = '正在启动模型服务...'
|
||||||
|
try {
|
||||||
|
// 当前为 mock 环境:不创建任务、不启动后端服务,
|
||||||
|
// 用假数据直通进入对话界面(模型名通过 query 传递)。
|
||||||
|
// 接入真实后端后,可在此恢复 createCompare / startModelsInBackground / monitorStartup 流程。
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||||
|
|
||||||
|
ElMessage.success('模型已启动')
|
||||||
|
router.push({
|
||||||
|
path: '/model-inference/chat/mock',
|
||||||
|
query: { model: m.name },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
startupStatus.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [db, trained, sys] = await Promise.all([
|
||||||
|
getModelList(),
|
||||||
|
getTrainedModels(),
|
||||||
|
getSystemInfo(),
|
||||||
|
])
|
||||||
|
dbModels.value = db || []
|
||||||
|
trainedModels.value = trained?.models || []
|
||||||
|
gpus.value = sys?.gpu || []
|
||||||
|
// 默认选中第一个 GPU
|
||||||
|
if (gpus.value.length > 0) form.gpu_id = 0
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="新建推理">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="推理名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入推理名称" maxlength="50" show-word-limit style="max-width: 400px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" show-word-limit style="max-width: 400px" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">选择模型</el-divider>
|
||||||
|
<el-form-item label="选择模型" prop="model_key">
|
||||||
|
<el-select
|
||||||
|
v-model="form.model_key"
|
||||||
|
placeholder="请选择模型"
|
||||||
|
filterable
|
||||||
|
style="width: 400px"
|
||||||
|
>
|
||||||
|
<el-option-group label="本地模型">
|
||||||
|
<el-option
|
||||||
|
v-for="m in dbOptions"
|
||||||
|
:key="m.key"
|
||||||
|
:label="m.name"
|
||||||
|
:value="m.key"
|
||||||
|
/>
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group label="已训练模型">
|
||||||
|
<el-option
|
||||||
|
v-for="m in trainedOptions"
|
||||||
|
:key="m.key"
|
||||||
|
:label="m.name + (m.disabled ? '(未合并)' : '')"
|
||||||
|
:value="m.key"
|
||||||
|
:disabled="m.disabled"
|
||||||
|
/>
|
||||||
|
</el-option-group>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="GPU">
|
||||||
|
<el-select v-model="form.gpu_id" style="width: 400px">
|
||||||
|
<el-option
|
||||||
|
v-for="(g, idx) in gpus"
|
||||||
|
:key="idx"
|
||||||
|
:label="`${g.name} (GPU${idx})`"
|
||||||
|
:value="idx"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="startupStatus" label="启动状态">
|
||||||
|
<el-alert :title="startupStatus" type="info" :closable="false" show-icon />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">开始推理</el-button>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
190
frontend/src/views/inference/InferenceListView.vue
Normal file
190
frontend/src/views/inference/InferenceListView.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import {
|
||||||
|
getCompareList,
|
||||||
|
deleteCompare,
|
||||||
|
getCompare,
|
||||||
|
loadCompare,
|
||||||
|
unloadCompare,
|
||||||
|
stopModelByPid,
|
||||||
|
} from '@/api/modules/compare'
|
||||||
|
import type { CompareTask, LoadedModel } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataList = ref<CompareTask[]>([])
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function loadData(silent = false) {
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dataList.value = (await getCompareList()) || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 load_status 判断就绪状态 */
|
||||||
|
function isStarting(row: any): boolean {
|
||||||
|
const models = parseLoadedModels(row)
|
||||||
|
return models.length > 0 && models.some((m) => m.status === 'starting')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReady(row: any): boolean {
|
||||||
|
const models = parseLoadedModels(row)
|
||||||
|
if (models.length === 0) return row.status === 'loaded' || row.status === 'ready'
|
||||||
|
return models.every((m) => m.status === 'ready' || m.status === 'running') && !isStarting(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLoadedModels(row: any): LoadedModel[] {
|
||||||
|
if (!row.load_status) return []
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof row.load_status === 'string' ? JSON.parse(row.load_status) : row.load_status
|
||||||
|
return parsed.loaded_models || []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析相关模型字段,返回模型名称列表 */
|
||||||
|
function parseModelNames(row: any): string[] {
|
||||||
|
if (!row.models) return []
|
||||||
|
// 数组结构直接取
|
||||||
|
if (Array.isArray(row.models)) {
|
||||||
|
return row.models.map((m: any) => m.model_name || m.name).filter(Boolean)
|
||||||
|
}
|
||||||
|
// 字符串结构需先 JSON.parse
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.models)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((m: any) => m.model_name || m.name).filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非法 JSON,忽略
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载推理任务 */
|
||||||
|
async function handleLoad(row: any) {
|
||||||
|
await loadCompare(row.id)
|
||||||
|
ElMessage.info('正在加载模型,请稍候...')
|
||||||
|
setTimeout(loadData, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载推理任务 */
|
||||||
|
async function handleUnload(row: any) {
|
||||||
|
await ElMessageBox.confirm('确定要停止模型服务吗?', '确认停止', { type: 'warning' })
|
||||||
|
await unloadCompare(row.id)
|
||||||
|
ElMessage.success('已停止模型服务')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除(先停止进程) */
|
||||||
|
async function handleDelete(row: any) {
|
||||||
|
// 先尝试停止已加载的模型进程
|
||||||
|
const task = await getCompare(row.id).catch(() => null)
|
||||||
|
if (task?.load_status) {
|
||||||
|
const models = parseLoadedModels(task as CompareTask)
|
||||||
|
for (const m of models) {
|
||||||
|
if (m.pid) {
|
||||||
|
await stopModelByPid(m.pid).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await deleteCompare(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始对话 */
|
||||||
|
function startChat(row: any) {
|
||||||
|
router.push(`/model-inference/chat/${row.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
refreshTimer = setInterval(() => loadData(true), 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DataTablePage
|
||||||
|
title="模型推理"
|
||||||
|
:data="dataList"
|
||||||
|
:loading="loading"
|
||||||
|
searchable
|
||||||
|
:search-fields="['name', 'model_name', 'description']"
|
||||||
|
create-text="新建推理"
|
||||||
|
create-to="/model-inference/create"
|
||||||
|
:delete-fn="handleDelete"
|
||||||
|
row-key="id"
|
||||||
|
@refresh="loadData"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="推理名称" align="center">
|
||||||
|
<template #default="{ row }">{{ row.model_name || row.name || '-' }}</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="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="isStarting(row)" type="warning" size="small">启动中</el-tag>
|
||||||
|
<el-tag v-else-if="isReady(row)" type="success" size="small">已就绪</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">{{ row.status || '未启动' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="相关模型" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ parseModelNames(row).join(',') || '-' }}
|
||||||
|
</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">
|
||||||
|
<template v-if="isStarting(row)">
|
||||||
|
<el-button type="warning" link size="small" :loading="true" disabled>
|
||||||
|
加载中
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isReady(row)">
|
||||||
|
<el-button type="success" link size="small" @click="startChat(row)">
|
||||||
|
<i class="fa fa-comments-o" style="margin-right: 4px" />对话
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" link size="small" @click="handleUnload(row)">
|
||||||
|
<i class="fa fa-stop-circle-o" style="margin-right: 4px" />停止
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-button type="primary" link size="small" @click="handleLoad(row)">
|
||||||
|
<i class="fa fa-play-circle-o" style="margin-right: 4px" />加载
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<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>
|
||||||
296
frontend/src/views/login/LoginView.vue
Normal file
296
frontend/src/views/login/LoginView.vue
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const loginFormRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const loginForm = reactive({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginFormRef.value) return
|
||||||
|
await loginFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(loginForm.username, loginForm.password)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/fine-tune')
|
||||||
|
} catch {
|
||||||
|
// 拦截器已提示错误
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<!-- LOGO 和标题 -->
|
||||||
|
<div class="login-header">
|
||||||
|
<img src="/logo.png" alt="Logo" class="login-logo" />
|
||||||
|
<h1 class="login-title">远光软件微调平台</h1>
|
||||||
|
<p class="login-subtitle">模型管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录表单 -->
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="rules"
|
||||||
|
size="large"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="请输入账号/手机号/邮箱"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix><i class="fa fa-user-o" /></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
>
|
||||||
|
<template #prefix><i class="fa fa-lock-o" /></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="login-options">
|
||||||
|
<el-checkbox>记住密码</el-checkbox>
|
||||||
|
<el-link type="primary" :underline="false">忘记密码?</el-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="login-btn"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
<i class="fa fa-sign-in login-btn-icon" />
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
/* Beautiful light mesh gradient for AI platform */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 10% 20%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 80% 0%, rgba(236, 72, 153, 0.15) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 40% 60%, rgba(56, 189, 248, 0.15) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 90% 80%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add some dynamic floating orbs for extra premium feel */
|
||||||
|
.login-page::before,
|
||||||
|
.login-page::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
z-index: 0;
|
||||||
|
animation: float 20s infinite ease-in-out alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::after {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: rgba(236, 72, 153, 0.08);
|
||||||
|
bottom: -150px;
|
||||||
|
right: -150px;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translate(0, 0) rotate(0deg); }
|
||||||
|
100% { transform: translate(50px, 50px) rotate(10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
/* Glassmorphism */
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 40px -10px rgba(0, 0, 0, 0.05),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.02),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||||
|
/* Entrance animation */
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
animation: slideUpFade 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpFade {
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
/* Gradient text */
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #334155 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Input Styling */
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(203, 213, 225, 0.5) inset !important;
|
||||||
|
padding: 0 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.6) inset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) inset, 0 0 0 1px #4f46e5 inset !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1e293b;
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__prefix) {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
margin-top: -8px;
|
||||||
|
|
||||||
|
:deep(.el-checkbox__label) {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
:deep(.el-link) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4f46e5;
|
||||||
|
&:hover {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
.login-btn-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.4);
|
||||||
|
background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
frontend/src/views/model/MergeWeightsView.vue
Normal file
106
frontend/src/views/model/MergeWeightsView.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { getTrainedModels, mergeModel } from '@/api/modules/model'
|
||||||
|
import { TRAIN_METHOD_MAP } from '@/constants'
|
||||||
|
import type { TrainedModel } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const merging = ref(false)
|
||||||
|
|
||||||
|
const modelName = computed(() => (route.query.model as string) || '')
|
||||||
|
const method = computed(() => (route.query.method as string) || 'lora')
|
||||||
|
|
||||||
|
const trainedModels = ref<TrainedModel[]>([])
|
||||||
|
const currentModel = computed(() => trainedModels.value.find((m) => m.name === modelName.value))
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
model_name: modelName.value,
|
||||||
|
train_method: method.value,
|
||||||
|
base_model_path: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadModel() {
|
||||||
|
try {
|
||||||
|
const res = await getTrainedModels()
|
||||||
|
trainedModels.value = res?.models || []
|
||||||
|
const target = trainedModels.value.find((m) => m.name === modelName.value)
|
||||||
|
form.base_model_path = target?.base_model_path || ''
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMerge() {
|
||||||
|
if (!form.model_name || !form.base_model_path) {
|
||||||
|
ElMessage.warning('缺少模型信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
merging.value = true
|
||||||
|
try {
|
||||||
|
await mergeModel({
|
||||||
|
model_name: form.model_name,
|
||||||
|
train_method: form.train_method,
|
||||||
|
base_model_path: form.base_model_path,
|
||||||
|
})
|
||||||
|
ElMessage.success('合并成功')
|
||||||
|
router.push('/model-manage')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
merging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadModel)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="合并权重">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="将 LoRA 适配器权重合并到基座模型,合并后可直接用于推理部署。"
|
||||||
|
style="margin-bottom: 20px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form label-width="120px" style="max-width: 600px">
|
||||||
|
<el-form-item label="模型名称">
|
||||||
|
<el-input v-model="form.model_name" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="训练方法">
|
||||||
|
<el-input :model-value="TRAIN_METHOD_MAP[form.train_method] || form.train_method" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="基座模型路径">
|
||||||
|
<el-input v-model="form.base_model_path" placeholder="基座模型路径" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="merging" @click="handleMerge">
|
||||||
|
{{ merging ? '合并中...' : '开始合并' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div v-if="currentModel" class="model-status">
|
||||||
|
<el-tag v-if="currentModel.merging" type="warning">合并中</el-tag>
|
||||||
|
<el-tag v-else-if="currentModel.merged" type="success">已合并</el-tag>
|
||||||
|
<el-tag v-else type="info">未合并</el-tag>
|
||||||
|
</div>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.model-status {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
231
frontend/src/views/model/ModelCreateView.vue
Normal file
231
frontend/src/views/model/ModelCreateView.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import {
|
||||||
|
getModel,
|
||||||
|
createModel,
|
||||||
|
updateModel,
|
||||||
|
getLocalModels,
|
||||||
|
} from '@/api/modules/model'
|
||||||
|
import { MODEL_TYPE_MAP } from '@/constants'
|
||||||
|
import type { ModelForm, ModelSource } 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 localModels = ref<{ path: string; name: string }[]>([])
|
||||||
|
|
||||||
|
const form = reactive<ModelForm>({
|
||||||
|
name: '',
|
||||||
|
type: 'LLM',
|
||||||
|
purpose: 'inference',
|
||||||
|
model_source: 'local',
|
||||||
|
description: '',
|
||||||
|
path: '',
|
||||||
|
api_url: '',
|
||||||
|
api_key: '',
|
||||||
|
online_model_name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入模型名称', trigger: 'blur' },
|
||||||
|
{ max: 100, message: '不超过 100 字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
type: [{ required: true, message: '请选择模型类型', trigger: 'change' }],
|
||||||
|
purpose: [{ required: true, message: '请选择模型用途', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 model_source 动态校验 */
|
||||||
|
function getSourceRules(): FormRules {
|
||||||
|
if (form.model_source === 'local') {
|
||||||
|
return { path: [{ required: true, message: '请选择本地模型路径', trigger: 'change' }] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api_url: [{ required: true, message: '请输入 API 地址', trigger: 'blur' }],
|
||||||
|
api_key: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
|
||||||
|
online_model_name: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = Object.entries(MODEL_TYPE_MAP).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
async function loadEditData() {
|
||||||
|
if (!editId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const model: any = await getModel(editId.value)
|
||||||
|
// 兼容旧数据:online → api
|
||||||
|
const source: ModelSource = model.model_source === 'online' ? 'api' : model.model_source || 'local'
|
||||||
|
Object.assign(form, {
|
||||||
|
name: model.name || '',
|
||||||
|
type: model.type || 'LLM',
|
||||||
|
purpose: model.purpose || 'inference',
|
||||||
|
model_source: source,
|
||||||
|
description: model.description || '',
|
||||||
|
path: model.path || '',
|
||||||
|
api_url: model.api_url || '',
|
||||||
|
api_key: model.api_key || '',
|
||||||
|
online_model_name: model.model_name || '',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalModels() {
|
||||||
|
try {
|
||||||
|
const res = await getLocalModels()
|
||||||
|
localModels.value = res?.models || []
|
||||||
|
} catch {
|
||||||
|
localModels.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
// 来源相关字段校验
|
||||||
|
if (form.model_source === 'local' && !form.path) {
|
||||||
|
ElMessage.warning('请选择本地模型路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data: ModelForm = {
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
purpose: form.purpose,
|
||||||
|
model_source: form.model_source,
|
||||||
|
description: form.description,
|
||||||
|
}
|
||||||
|
if (form.model_source === 'local') {
|
||||||
|
data.path = form.path
|
||||||
|
} else {
|
||||||
|
data.api_url = form.api_url
|
||||||
|
data.api_key = form.api_key
|
||||||
|
data.online_model_name = form.online_model_name
|
||||||
|
}
|
||||||
|
if (isEdit.value && editId.value) {
|
||||||
|
await updateModel(editId.value, data)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createModel(data)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
router.push('/model-manage')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLocalModels()
|
||||||
|
loadEditData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard :title="isEdit ? '编辑模型' : '添加模型'" v-loading="loading">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="{ ...rules, ...getSourceRules() }"
|
||||||
|
label-width="120px"
|
||||||
|
style="max-width: 640px"
|
||||||
|
>
|
||||||
|
<el-form-item label="模型名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入模型名称" maxlength="100" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="模型类型" prop="type">
|
||||||
|
<el-select v-model="form.type" placeholder="请选择模型类型" style="width: 100%">
|
||||||
|
<el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="模型用途" prop="purpose">
|
||||||
|
<el-radio-group v-model="form.purpose">
|
||||||
|
<el-radio value="training">训练基座</el-radio>
|
||||||
|
<el-radio value="inference">推理对比</el-radio>
|
||||||
|
<el-radio value="evaluation">评测</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="模型来源" prop="model_source">
|
||||||
|
<el-radio-group v-model="form.model_source">
|
||||||
|
<el-radio value="local">本地模型</el-radio>
|
||||||
|
<el-radio value="api">在线模型</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 本地模型 -->
|
||||||
|
<template v-if="form.model_source === 'local'">
|
||||||
|
<el-form-item label="本地模型路径" prop="path">
|
||||||
|
<el-select
|
||||||
|
v-model="form.path"
|
||||||
|
placeholder="请选择本地模型路径"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="m in localModels"
|
||||||
|
:key="m.path"
|
||||||
|
:label="`${m.name} (${m.path})`"
|
||||||
|
:value="m.path"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 在线 API 模型 -->
|
||||||
|
<template v-else>
|
||||||
|
<el-form-item label="API 地址" prop="api_url">
|
||||||
|
<el-input v-model="form.api_url" placeholder="如:https://api.openai.com/v1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API Key" prop="api_key">
|
||||||
|
<el-input v-model="form.api_key" type="password" show-password placeholder="请输入 API Key" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型名称" prop="online_model_name">
|
||||||
|
<el-input v-model="form.online_model_name" placeholder="如:gpt-4、qwen-turbo" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="模型描述">
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
309
frontend/src/views/model/ModelManageView.vue
Normal file
309
frontend/src/views/model/ModelManageView.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import {
|
||||||
|
getModelList,
|
||||||
|
getTrainedModels,
|
||||||
|
deleteModel,
|
||||||
|
deleteTrainedModel,
|
||||||
|
mergeModel,
|
||||||
|
exportModelUrl,
|
||||||
|
} from '@/api/modules/model'
|
||||||
|
import { MODEL_TYPE_MAP, PURPOSE_MAP, MODEL_SOURCE_MAP } from '@/constants'
|
||||||
|
import type { ModelItem, TrainedModel } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
type TabKey = 'config' | 'trained'
|
||||||
|
const activeTab = ref<TabKey>('config')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const configList = ref<ModelItem[]>([])
|
||||||
|
const trainedList = ref<TrainedModel[]>([])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
configList.value = (await getModelList()) || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrained() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getTrainedModels()
|
||||||
|
trainedList.value = res?.models || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
if (activeTab.value === 'config') loadConfig()
|
||||||
|
else loadTrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除配置模型 */
|
||||||
|
async function handleDeleteConfig(row: any) {
|
||||||
|
await deleteModel(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除已训练模型(合并权重) */
|
||||||
|
async function handleDeleteTrained(row: any) {
|
||||||
|
await deleteTrainedModel(row.id || row.name, 'merged')
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 LoRA 权重 */
|
||||||
|
async function handleDeleteWeight(row: any) {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除模型 "${row.name}" 的权重文件吗?合并模型不受影响。`,
|
||||||
|
'确认删除',
|
||||||
|
{ type: 'warning' },
|
||||||
|
)
|
||||||
|
await deleteTrainedModel(row.name, 'lora')
|
||||||
|
ElMessage.success('权重已删除')
|
||||||
|
loadTrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并权重 */
|
||||||
|
async function handleMerge(row: any) {
|
||||||
|
await router.push({
|
||||||
|
path: '/model-manage/merge',
|
||||||
|
query: {
|
||||||
|
model: row.name,
|
||||||
|
method: row.train_methods?.[0]?.name || 'lora',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出 */
|
||||||
|
function handleExport(row: any) {
|
||||||
|
window.open(exportModelUrl(row.name), '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function editModel(row: any) {
|
||||||
|
router.push(`/model-manage/${row.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateClick() {
|
||||||
|
if (activeTab.value === 'config') {
|
||||||
|
router.push('/model-manage/create')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
if (activeTab.value === 'config') {
|
||||||
|
loadConfig()
|
||||||
|
} else {
|
||||||
|
loadTrained()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, () => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="model-manage-page" style="height: 100%;">
|
||||||
|
<!-- 配置模型列表 -->
|
||||||
|
<DataTablePage
|
||||||
|
v-if="activeTab === 'config'"
|
||||||
|
title=""
|
||||||
|
:data="configList"
|
||||||
|
:loading="loading"
|
||||||
|
searchable
|
||||||
|
:search-fields="['name', 'description']"
|
||||||
|
create-text="添加模型"
|
||||||
|
@create="handleCreateClick"
|
||||||
|
:delete-fn="handleDeleteConfig"
|
||||||
|
row-key="id"
|
||||||
|
@refresh="loadConfig"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button class="capsule-tab-item active" @click="activeTab = 'config'">配置模型</button>
|
||||||
|
<button class="capsule-tab-item" @click="activeTab = 'trained'">训练模型</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="模型名称" prop="name" align="center" />
|
||||||
|
<el-table-column label="模型类型" align="center" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="primary" size="small">
|
||||||
|
{{ MODEL_TYPE_MAP[row.type] || row.type || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用途" align="center" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="(PURPOSE_MAP[row.purpose]?.type || 'info') as any" size="small">
|
||||||
|
{{ PURPOSE_MAP[row.purpose]?.text || row.purpose || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="模型来源" align="center" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ MODEL_SOURCE_MAP[row.model_source] || row.model_source || '-' }}
|
||||||
|
</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 }">
|
||||||
|
<el-button type="primary" link size="small" @click="editModel(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDeleteConfig(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
|
||||||
|
<!-- 训练模型列表 -->
|
||||||
|
<DataTablePage
|
||||||
|
v-else
|
||||||
|
title=""
|
||||||
|
:data="trainedList"
|
||||||
|
:loading="loading"
|
||||||
|
searchable
|
||||||
|
:search-fields="['name']"
|
||||||
|
:delete-fn="handleDeleteTrained"
|
||||||
|
row-key="name"
|
||||||
|
@refresh="loadTrained"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="capsule-tabs">
|
||||||
|
<button class="capsule-tab-item" @click="activeTab = 'config'">配置模型</button>
|
||||||
|
<button class="capsule-tab-item active" @click="activeTab = 'trained'">训练模型</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column label="模型名称" prop="name" align="center" />
|
||||||
|
<el-table-column label="训练方法" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.train_methods?.[0]?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="基座模型" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.base_model_path || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="合并状态" align="center" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.merging" type="warning" size="small">合并中</el-tag>
|
||||||
|
<el-tag v-else-if="row.merged" type="success" size="small">已合并</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未合并</el-tag>
|
||||||
|
</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
|
||||||
|
v-if="!row.merged && !row.merging"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleMerge(row)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-code-fork" style="margin-right: 4px" />合并权重
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else-if="row.merging"
|
||||||
|
type="info"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
:loading="true"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
合并中
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" link size="small" @click="handleDeleteWeight(row)">
|
||||||
|
<i class="fa fa-eraser" style="margin-right: 4px" />删除权重
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.merged"
|
||||||
|
type="success"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleExport(row)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-download" style="margin-right: 4px" />导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.merged"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleDeleteTrained(row)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
frontend/src/views/system/HardwareView.vue
Normal file
283
frontend/src/views/system/HardwareView.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import type { SystemInfo } from '@/types'
|
||||||
|
|
||||||
|
const refreshInterval = ref(5000)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const info = ref<SystemInfo>({})
|
||||||
|
|
||||||
|
async function fetchInfo() {
|
||||||
|
try {
|
||||||
|
info.value = await getSystemInfo()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
stopTimer()
|
||||||
|
timer = setInterval(fetchInfo, refreshInterval.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInterval(val: number) {
|
||||||
|
refreshInterval.value = val
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 温度颜色 */
|
||||||
|
function tempColor(t?: number) {
|
||||||
|
if (t == null) return ''
|
||||||
|
if (t >= 80) return '#f56c6c'
|
||||||
|
if (t >= 70) return '#e6a23c'
|
||||||
|
return '#303133'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchInfo()
|
||||||
|
startTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(stopTimer)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="平台性能">
|
||||||
|
<template #extra>
|
||||||
|
<span style="font-size: 13px; color: #909399">刷新频率:</span>
|
||||||
|
<el-select v-model="refreshInterval" size="small" style="width: 90px" @change="changeInterval">
|
||||||
|
<el-option :value="1000" label="1秒" />
|
||||||
|
<el-option :value="3000" label="3秒" />
|
||||||
|
<el-option :value="5000" label="5秒" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- CPU / 内存 / 磁盘 -->
|
||||||
|
<el-row :gutter="16" style="margin-bottom: 16px">
|
||||||
|
<!-- CPU -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="metric-header">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-microchip metric-icon cpu-icon" />
|
||||||
|
<div class="metric-info">
|
||||||
|
<div class="metric-title">CPU 使用率</div>
|
||||||
|
<div class="metric-sub">{{ info.cpu?.cores || 0 }} 核心</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-value">{{ info.cpu?.percent || 0 }}%</div>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="info.cpu?.percent || 0" :stroke-width="10" :show-text="false" color="#1890ff" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 内存 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="metric-header">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-database metric-icon mem-icon" />
|
||||||
|
<div class="metric-info">
|
||||||
|
<div class="metric-title">内存使用</div>
|
||||||
|
<div class="metric-sub">总计: {{ info.memory?.total_gb || 0 }} GB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-value">{{ info.memory?.percent || 0 }}%</div>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="info.memory?.percent || 0" :stroke-width="10" :show-text="false" color="#e6a23c" />
|
||||||
|
<div class="metric-detail">
|
||||||
|
<span>已用 {{ info.memory?.used_gb || 0 }} GB</span>
|
||||||
|
<span>可用 {{ info.memory?.available_gb || 0 }} GB</span>
|
||||||
|
<span>缓存 {{ info.memory?.cached_gb || 0 }} GB</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 磁盘 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="metric-header">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-hdd-o metric-icon disk-icon" />
|
||||||
|
<div class="metric-info">
|
||||||
|
<div class="metric-title">磁盘使用</div>
|
||||||
|
<div class="metric-sub">总计: {{ info.disk?.total_gb || 0 }} GB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-value">{{ info.disk?.percent || 0 }}%</div>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="info.disk?.percent || 0" :stroke-width="10" :show-text="false" color="#67c23a" />
|
||||||
|
<div class="metric-detail">
|
||||||
|
<span>已用 {{ info.disk?.used_gb || 0 }} GB</span>
|
||||||
|
<span>可用 {{ (info.disk?.total_gb || 0) - (info.disk?.used_gb || 0) }} GB</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- GPU -->
|
||||||
|
<el-card shadow="never" style="margin-bottom: 16px">
|
||||||
|
<template #header>
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="fa fa-microchip" style="color: #f56c6c" />
|
||||||
|
<span>GPU 监控({{ info.gpu?.length || 0 }} 块)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col v-for="(gpu, idx) in info.gpu" :key="idx" :span="6">
|
||||||
|
<el-card shadow="never" class="gpu-card">
|
||||||
|
<div class="gpu-name">{{ gpu.name }}</div>
|
||||||
|
<div class="gpu-usage">{{ gpu.gpu_percent }}%</div>
|
||||||
|
<el-progress :percentage="gpu.gpu_percent" :stroke-width="8" :show-text="false" />
|
||||||
|
<div class="gpu-meta">
|
||||||
|
<div>显存: {{ gpu.memory_used_gb }}/{{ gpu.memory_total_gb }} GB</div>
|
||||||
|
<div>温度: <span :style="{ color: tempColor(gpu.temperature) }">{{ gpu.temperature }}°C</span></div>
|
||||||
|
<div>功耗: {{ gpu.power_w }} W</div>
|
||||||
|
<div>风扇: {{ gpu.fan_speed || 0 }}%</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 系统信息 -->
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="fa fa-globe" style="color: #909399" />
|
||||||
|
<span>网络流量</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="metric-detail">
|
||||||
|
<span>总流入: {{ info.network?.download_mb || 0 }} GB</span>
|
||||||
|
<span>总流出: {{ info.network?.upload_mb || 0 }} GB</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="fa fa-info-circle" style="color: #909399" />
|
||||||
|
<span>系统信息</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="sys-info">
|
||||||
|
<div class="sys-row"><span>操作系统</span><span>{{ info.system?.os || '-' }}</span></div>
|
||||||
|
<div class="sys-row"><span>进程数</span><span>{{ info.system?.process_count || 0 }}</span></div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.metric-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.cpu-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
.mem-icon {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
.disk-icon {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.metric-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.metric-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.metric-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-card {
|
||||||
|
.gpu-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.gpu-usage {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f56c6c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.gpu-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-info {
|
||||||
|
.sys-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span:first-child {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
250
frontend/src/views/system/LogsView.vue
Normal file
250
frontend/src/views/system/LogsView.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { useCountdown } from '@/composables/useCountdown'
|
||||||
|
import {
|
||||||
|
getLogFiles,
|
||||||
|
getLogContent,
|
||||||
|
getTrainingLogFiles,
|
||||||
|
getTrainingLogContent,
|
||||||
|
} from '@/api/modules/log'
|
||||||
|
import type { LogFile } from '@/types'
|
||||||
|
|
||||||
|
const activeTab = ref<'system' | 'training'>('system')
|
||||||
|
|
||||||
|
// 系统日志
|
||||||
|
const sysDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const sysFiles = ref<LogFile[]>([])
|
||||||
|
const sysSelected = ref('')
|
||||||
|
const sysContent = ref('')
|
||||||
|
|
||||||
|
// 训练日志
|
||||||
|
const trainFiles = ref<LogFile[]>([])
|
||||||
|
const trainSelected = ref('')
|
||||||
|
const trainContent = ref('')
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const keyword = ref('')
|
||||||
|
const fullContent = ref('')
|
||||||
|
|
||||||
|
// 自动刷新
|
||||||
|
const refreshInterval = ref(10)
|
||||||
|
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const filteredContent = computed(() => {
|
||||||
|
if (!keyword.value.trim()) return fullContent.value
|
||||||
|
const kw = keyword.value.toLowerCase().trim()
|
||||||
|
return fullContent.value
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.toLowerCase().includes(kw))
|
||||||
|
.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchCount = computed(() => {
|
||||||
|
if (!keyword.value.trim()) return 0
|
||||||
|
const kw = keyword.value.toLowerCase().trim()
|
||||||
|
return fullContent.value.split('\n').filter((line) => line.toLowerCase().includes(kw)).length
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSysFiles() {
|
||||||
|
try {
|
||||||
|
sysFiles.value = (await getLogFiles(sysDate.value)) || []
|
||||||
|
if (sysFiles.value.length > 0) {
|
||||||
|
sysSelected.value = sysFiles.value[0].file
|
||||||
|
loadSysContent()
|
||||||
|
} else {
|
||||||
|
sysContent.value = '该日期暂无日志文件'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sysFiles.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSysContent() {
|
||||||
|
if (!sysSelected.value) return
|
||||||
|
try {
|
||||||
|
const res = await getLogContent(sysSelected.value)
|
||||||
|
fullContent.value = res.content || ''
|
||||||
|
sysContent.value = fullContent.value
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrainFiles() {
|
||||||
|
try {
|
||||||
|
trainFiles.value = (await getTrainingLogFiles()) || []
|
||||||
|
if (trainFiles.value.length > 0) {
|
||||||
|
trainSelected.value = trainFiles.value[0].file
|
||||||
|
loadTrainContent()
|
||||||
|
} else {
|
||||||
|
trainContent.value = '暂无训练日志'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
trainFiles.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrainContent() {
|
||||||
|
if (!trainSelected.value) return
|
||||||
|
try {
|
||||||
|
const res = await getTrainingLogContent(trainSelected.value)
|
||||||
|
fullContent.value = res.content || ''
|
||||||
|
trainContent.value = fullContent.value
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (activeTab.value === 'system') {
|
||||||
|
loadSysContent()
|
||||||
|
} else {
|
||||||
|
loadTrainContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
stopAutoRefresh()
|
||||||
|
if (refreshInterval.value === 0) {
|
||||||
|
stopCountdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startCountdown()
|
||||||
|
refreshTimer = setInterval(refresh, refreshInterval.value * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(refreshInterval, startAutoRefresh)
|
||||||
|
watch(activeTab, () => {
|
||||||
|
keyword.value = ''
|
||||||
|
if (activeTab.value === 'system') loadSysFiles()
|
||||||
|
else loadTrainFiles()
|
||||||
|
})
|
||||||
|
watch(sysSelected, loadSysContent)
|
||||||
|
watch(trainSelected, loadTrainContent)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSysFiles()
|
||||||
|
startAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(stopAutoRefresh)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="查看日志">
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<el-tabs v-model="activeTab" style="margin-bottom: 16px">
|
||||||
|
<el-tab-pane label="系统日志" name="system" />
|
||||||
|
<el-tab-pane label="训练日志" name="training" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 系统日志选项 -->
|
||||||
|
<div v-if="activeTab === 'system'" class="log-options">
|
||||||
|
<div class="option-group">
|
||||||
|
<span class="option-label">选择日期:</span>
|
||||||
|
<el-date-picker v-model="sysDate" type="date" value-format="YYYY-MM-DD" style="width: 160px" @change="loadSysFiles" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<span class="option-label">日志类型:</span>
|
||||||
|
<el-select v-if="activeTab === 'system'" v-model="sysSelected" placeholder="请选择日志文件" style="width: 320px">
|
||||||
|
<el-option v-for="f in sysFiles" :key="f.file" :label="`${f.name} (${f.size})`" :value="f.file" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-else v-model="trainSelected" placeholder="请选择训练日志" style="width: 320px">
|
||||||
|
<el-option v-for="f in trainFiles" :key="f.file" :label="`${f.name} (PID: ${f.pid})`" :value="f.file" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<span class="option-label">自动刷新:</span>
|
||||||
|
<el-select v-model="refreshInterval" style="width: 100px">
|
||||||
|
<el-option :value="0" label="关闭" />
|
||||||
|
<el-option :value="5" label="5秒" />
|
||||||
|
<el-option :value="10" label="10秒" />
|
||||||
|
<el-option :value="30" label="30秒" />
|
||||||
|
<el-option :value="60" label="60秒" />
|
||||||
|
</el-select>
|
||||||
|
<span v-if="refreshInterval > 0" class="countdown">下次刷新: {{ remaining }}秒</span>
|
||||||
|
<el-button @click="refresh">立即刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志内容 -->
|
||||||
|
<div class="log-content-box">
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
|
||||||
|
<template #prefix><i class="fa fa-search" /></template>
|
||||||
|
</el-input>
|
||||||
|
<span v-if="keyword" class="match-count">{{ matchCount }} 条匹配</span>
|
||||||
|
</div>
|
||||||
|
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
|
||||||
|
</div>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.log-options {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-box {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.match-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1026
frontend/src/views/system/TrainingLogView.vue
Normal file
1026
frontend/src/views/system/TrainingLogView.vue
Normal file
File diff suppressed because it is too large
Load Diff
142
frontend/src/views/tools/ToolCreateView.vue
Normal file
142
frontend/src/views/tools/ToolCreateView.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { useToolsStore } from '@/stores/tools'
|
||||||
|
import { TOOL_ICONS } from '@/constants'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toolsStore = useToolsStore()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const isEdit = computed(() => route.params.id != null)
|
||||||
|
const editingTool = computed(() =>
|
||||||
|
isEdit.value ? toolsStore.getTool(route.params.id as string) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
url: '',
|
||||||
|
icon: 'fa-cog',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入工具名称', trigger: 'blur' },
|
||||||
|
{ max: 30, message: '不超过 30 字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
url: [{ required: true, message: '请输入跳转地址', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
formRef.value.validate((valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
if (isEdit.value) {
|
||||||
|
toolsStore.updateTool(form.id, { ...form })
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
toolsStore.addTool({ ...form, id: 'custom_' + Date.now() })
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
router.push('/tools')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (editingTool.value) {
|
||||||
|
Object.assign(form, editingTool.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard :title="isEdit ? '编辑自定义工具' : '添加自定义工具'">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="100px"
|
||||||
|
style="max-width: 600px"
|
||||||
|
>
|
||||||
|
<el-form-item label="工具名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入工具名称" maxlength="30" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="工具描述">
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="跳转地址" prop="url">
|
||||||
|
<el-input v-model="form.url" placeholder="相对路径或完整 URL" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="图标">
|
||||||
|
<div class="icon-grid">
|
||||||
|
<div
|
||||||
|
v-for="icon in TOOL_ICONS"
|
||||||
|
:key="icon"
|
||||||
|
class="icon-option"
|
||||||
|
:class="{ selected: form.icon === icon }"
|
||||||
|
@click="form.icon = icon"
|
||||||
|
>
|
||||||
|
<i class="fa" :class="icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSubmit">{{ isEdit ? '保存' : '添加' }}</el-button>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-option {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #606266;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
frontend/src/views/tools/ToolsView.vue
Normal file
153
frontend/src/views/tools/ToolsView.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { useToolsStore } from '@/stores/tools'
|
||||||
|
import { DEFAULT_TOOLS } from '@/constants'
|
||||||
|
import type { CustomTool } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toolsStore = useToolsStore()
|
||||||
|
const { tools } = storeToRefs(toolsStore)
|
||||||
|
|
||||||
|
function handleDefaultTool(id: string) {
|
||||||
|
if (id === 'data-generate') {
|
||||||
|
ElMessage.info('数据生成工具开发中...')
|
||||||
|
} else if (id === 'json2jsonl') {
|
||||||
|
ElMessage.info('JSON转JSONL 工具开发中...')
|
||||||
|
} else if (id === 'md-convert') {
|
||||||
|
ElMessage.info('转换Markdown 工具开发中...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomTool(tool: CustomTool) {
|
||||||
|
if (tool.url.startsWith('http')) {
|
||||||
|
window.open(tool.url, '_blank')
|
||||||
|
} else {
|
||||||
|
router.push(tool.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTool(tool: CustomTool) {
|
||||||
|
router.push(`/tools/${tool.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTool(tool: CustomTool) {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个自定义工具吗?', '确认删除', { type: 'warning' })
|
||||||
|
toolsStore.removeTool(tool.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard title="其他工具">
|
||||||
|
<template #extra>
|
||||||
|
<el-button type="primary" @click="router.push('/tools/create')">
|
||||||
|
<i class="fa fa-plus" style="margin-right: 4px" />添加自定义工具
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 默认工具 -->
|
||||||
|
<h3 class="section-title">默认工具</h3>
|
||||||
|
<el-row :gutter="16" style="margin-bottom: 24px">
|
||||||
|
<el-col v-for="tool in DEFAULT_TOOLS" :key="tool.id" :span="8">
|
||||||
|
<el-card shadow="hover" class="tool-card" @click="handleDefaultTool(tool.id)">
|
||||||
|
<div class="tool-icon">
|
||||||
|
<i class="fa" :class="tool.icon" />
|
||||||
|
</div>
|
||||||
|
<h4 class="tool-name">{{ tool.name }}</h4>
|
||||||
|
<p class="tool-desc">{{ tool.description }}</p>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 自定义工具 -->
|
||||||
|
<h3 class="section-title">自定义工具</h3>
|
||||||
|
<el-row v-if="tools.length" :gutter="16">
|
||||||
|
<el-col v-for="tool in tools" :key="tool.id" :span="8">
|
||||||
|
<el-card shadow="hover" class="tool-card" @click="handleCustomTool(tool)">
|
||||||
|
<div class="tool-actions">
|
||||||
|
<el-button link size="small" @click.stop="editTool(tool)">
|
||||||
|
<i class="fa fa-pencil" />
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click.stop="deleteTool(tool)">
|
||||||
|
<i class="fa fa-times" />
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-icon">
|
||||||
|
<i class="fa" :class="tool.icon" />
|
||||||
|
</div>
|
||||||
|
<h4 class="tool-name">{{ tool.name }}</h4>
|
||||||
|
<p class="tool-desc">{{ tool.description }}</p>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-empty v-else description="暂无自定义工具,点击右上角添加" />
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #606266;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .tool-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user