Files
YG_FT/frontend/src/views/fine-tune/FineTuneCreateView.vue
wuyongtao 525fc55cef fix: 修复 GPU 选择索引错误及 CUDA 不可用问题,优化 GPU 硬件单选
- 修复 FineTuneCreateView GPU 选择使用 v-for idx 替代真实 gpu.id 导致
  多节点环境下 GPU 索引错误(如 gpu-node-02 仅 GPU 0 但请求 GPU 1)
- GPU 选择改为单选模式,已离线节点自动过滤不展示
- GPU 卡片增加节点编号展示
- 修复 CUDA_VISIBLE_DEVICES=all 无效值导致 torch.cuda.is_available() False
  改为 CUDA_VISIBLE_DEVICES=0
- .gitignore 新增 .claude/ CLAUDE.md 排除规则
- GpuInfo 类型增加 node_id/node_code/node_name 字段

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 12:43:20 +08:00

925 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
preflightFineTune,
startFineTune,
updateFineTune,
checkFineTuneName,
type FineTunePreflightResult,
} from '@/api/modules/fineTune'
import { getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
import { getComputeNodes } from '@/api/modules/compute'
import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS, QUANTIZATION_BIT_OPTIONS, QUANT_METHOD_OPTIONS, GGUF_FORMAT_OPTIONS } from '@/constants'
import {
DEFAULT_TRAINING_PARAMS,
buildFineTuneCommand,
buildFineTunePayload,
createDefaultFineTuneForm,
toCreateFineTunePayload,
} from './fineTuneFormModel'
import type { FineTuneFormModel } from './fineTuneFormModel'
import type { ModelItem, DatasetItem, GpuInfo } from '@/types'
const router = useRouter()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const preflightLoading = ref(false)
const preflightResult = ref<FineTunePreflightResult | null>(null)
const models = ref<ModelItem[]>([])
const datasets = ref<DatasetItem[]>([])
const gpus = ref<GpuInfo[]>([])
const computeNodes = ref<Array<{ id: string; scheduler_status?: string }>>([])
const selectedGpuId = ref<number | null>(null)
/** Only show GPUs from nodes that are online or draining */
const availableGpus = computed(() => {
const onlineNodeIds = new Set(
computeNodes.value
.filter((n) => n.scheduler_status === 'online' || n.scheduler_status === 'draining')
.map((n) => n.id),
)
return gpus.value.filter((gpu) => !gpu.node_id || onlineNodeIds.has(gpu.node_id))
})
const modelDialogVisible = ref(false)
const form = reactive(createDefaultFineTuneForm())
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 selectedGpuIds = computed(() => (selectedGpuId.value != null ? [selectedGpuId.value] : []))
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpuIds.value))
const remoteCommandPreview = computed(() => {
const command = preflightResult.value?.preview?.command
if (Array.isArray(command) && command.length) return command.join(' ')
return preflightResult.value?.preview?.command_text || ''
})
/** GPU 单选切换(每次只选中一张 GPU */
function toggleGpu(gpuId: number) {
selectedGpuId.value = selectedGpuId.value === gpuId ? null : gpuId
}
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, DEFAULT_TRAINING_PARAMS)
}
const isParamsExpanded = ref(false)
interface ParameterDefinition {
key: keyof FineTuneFormModel
name: string
desc: string
hint: string
type: 'number' | 'select'
min?: number
max?: number
step?: number
precision?: number
options?: Array<{ label: string; value: string | number }>
}
const allParams = computed(() => {
const params: ParameterDefinition[] = [
{ 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)
})
function parameterValue(key: keyof FineTuneFormModel) {
const value = form[key]
return typeof value === 'boolean' ? Number(value) : value
}
function updateParameterValue(key: keyof FineTuneFormModel, value: string | number | undefined) {
if (value !== undefined) Reflect.set(form, key, value)
}
async function loadModels() {
try {
models.value = (await getModelList()) || []
} catch {
models.value = []
}
}
async function loadDatasets() {
try {
datasets.value = ((await getDatasetList()) || []).filter(
(dataset) => String(dataset.type).toLowerCase() === 'train',
)
} catch {
datasets.value = []
}
}
async function loadGpus() {
try {
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
gpus.value = sys?.gpu || []
computeNodes.value = nodes || []
// Default select first available GPU
if (availableGpus.value.length > 0) selectedGpuId.value = availableGpus.value[0].id ?? null
} catch {
gpus.value = []
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (selectedGpuId.value == null) {
ElMessage.warning('请选择一个 GPU')
return
}
submitting.value = true
try {
let check: { exists: boolean }
try {
check = await checkFineTuneName(form.name)
} catch {
ElMessage.error('任务名校验失败,请稍后重试')
return
}
if (check.exists) {
ElMessage.error('任务名称已存在,请更换')
return
}
const payload = buildFineTunePayload(form, selectedGpuIds.value)
const preflight = await runPreflight(payload)
if (!preflight?.valid) {
ElMessage.error('训练预检未通过,请先处理预检问题')
return
}
const createRes = await createFineTune(toCreateFineTunePayload(payload))
const taskId = createRes.id
try {
await startFineTune({ ...payload, task_id: taskId })
ElMessage.success('训练任务已创建并启动')
} catch {
await updateFineTune(taskId, { status: 'failed' })
ElMessage.error('任务已创建,但训练启动失败')
}
router.push('/fine-tune')
} catch {
ElMessage.error('训练任务创建失败,请稍后重试')
} finally {
submitting.value = false
}
})
}
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value)) {
preflightLoading.value = true
try {
const result = await preflightFineTune(payload)
preflightResult.value = result
if (result.valid) {
ElMessage.success('训练预检通过')
} else {
ElMessage.warning('训练预检未通过')
}
return result
} catch {
preflightResult.value = {
valid: false,
errors: ['预检接口调用失败,请检查后端服务和算力节点连接'],
warnings: [],
diagnostics: [{ level: 'error', title: '预检调用失败', suggestion: '请确认后端服务可访问 Compute API并检查浏览器或后端日志。' }],
}
ElMessage.error('训练预检失败')
return preflightResult.value
} finally {
preflightLoading.value = false
}
}
async function handlePreflightClick() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (selectedGpuId.value == null) {
ElMessage.warning('请选择一个 GPU')
return
}
await runPreflight()
})
}
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 in availableGpus"
:key="gpu.id"
class="gpu-card"
:class="{ active: selectedGpuId === gpu.id, 'is-busy': gpu.gpu_percent > 80 }"
@click="toggleGpu(gpu.id!)"
>
<div class="gpu-card-top">
<div class="gpu-title">
<span class="gpu-index">
GPU-{{ gpu.id }}
<template v-if="gpu.node_code"> · {{ gpu.node_code }}</template>
</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="!availableGpus.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'"
:model-value="Number(parameterValue(param.key))"
:min="param.min" :max="param.max" :step="param.step" :precision="param.precision"
controls-position="right"
style="width: 200px"
@update:model-value="updateParameterValue(param.key, $event)"
/>
<el-select
v-else-if="param.type === 'select'"
:model-value="parameterValue(param.key)"
style="width: 200px"
@update:model-value="updateParameterValue(param.key, $event)"
>
<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>
<template v-if="form.train_type === 'SFT'">
<el-divider content-position="left">合并模型</el-divider>
<el-form-item label="自动合并权重并保存">
<el-select v-model="form.auto_merge" style="width: 420px;">
<el-option label="否" :value="false" />
<el-option label="是" :value="true" />
</el-select>
</el-form-item>
</template>
<!-- 模型量化 -->
<el-divider content-position="left">模型量化</el-divider>
<!-- 训练时量化QLoRA LoRA 训练时可用 -->
<el-form-item v-if="form.train_method === 'lora'" label="训练时量化">
<el-select v-model="form.quantization_bit" style="width: 420px;">
<el-option
v-for="opt in QUANTIZATION_BIT_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<!-- 训练后导出量化模型 -->
<el-form-item label="导出量化模型">
<el-select v-model="form.export_quantized" style="width: 420px;">
<el-option label="否" :value="false" />
<el-option label="是" :value="true" />
</el-select>
</el-form-item>
<template v-if="form.export_quantized">
<el-form-item label="量化方法">
<el-select v-model="form.quant_method" style="width: 420px;">
<el-option
v-for="opt in QUANT_METHOD_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="量化位数">
<el-input-number
v-model="form.quant_bits"
:min="form.quant_method === 'bnb' ? 4 : 2"
:max="form.quant_method === 'bnb' ? 8 : 16"
:step="1"
controls-position="right"
style="width: 200px"
/>
</el-form-item>
<el-form-item v-if="form.quant_method === 'gptq' || form.quant_method === 'awq'" label="分组大小">
<el-input-number
v-model="form.quant_group_size"
:min="32"
:max="1024"
:step="32"
controls-position="right"
style="width: 200px"
/>
</el-form-item>
<el-form-item v-if="form.quant_method === 'gguf'" label="导出格式">
<el-select v-model="form.export_format" style="width: 420px;">
<el-option
v-for="opt in GGUF_FORMAT_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</template>
<!-- 训练命令预览 -->
<el-divider content-position="left">训练命令预览</el-divider>
<div class="preflight-actions">
<el-button type="primary" plain :loading="preflightLoading" @click="handlePreflightClick">
<i class="fa fa-check-circle-o" style="margin-right: 4px;" /> 预检训练配置
</el-button>
<span class="preflight-hint">预检会调用后端调度和 Compute validate提前检查数据格式模型路径GPU 条件和真实训练命令</span>
</div>
<div class="command-preview-wrapper">
<pre class="command-preview">{{ commandPreview }}</pre>
</div>
<div v-if="preflightResult" class="preflight-panel" :class="{ 'is-valid': preflightResult.valid, 'is-invalid': !preflightResult.valid }">
<div class="preflight-header">
<strong>{{ preflightResult.valid ? '预检通过' : '预检未通过' }}</strong>
<span v-if="preflightResult.node">
调度节点{{ preflightResult.node.code || preflightResult.node.name || preflightResult.node.id }}
</span>
</div>
<div v-if="remoteCommandPreview" class="preflight-section">
<span class="section-label">后端真实命令</span>
<pre class="command-preview remote">{{ remoteCommandPreview }}</pre>
</div>
<div v-if="preflightResult.errors?.length" class="preflight-section">
<span class="section-label">错误</span>
<ul>
<li v-for="item in preflightResult.errors" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="preflightResult.warnings?.length" class="preflight-section">
<span class="section-label">警告</span>
<ul>
<li v-for="item in preflightResult.warnings" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="preflightResult.diagnostics?.length" class="preflight-section">
<span class="section-label">诊断建议</span>
<ul>
<li v-for="item in preflightResult.diagnostics" :key="item.title">
<strong>{{ item.title }}</strong>{{ item.suggestion }}
</li>
</ul>
</div>
</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;
}
.preflight-actions {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 12px 80px;
}
.preflight-hint {
color: #64748b;
font-size: 12px;
}
.preflight-panel {
margin: -8px 0 24px 80px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px 16px;
background: #f8fafc;
&.is-valid {
border-color: #bbf7d0;
background: #f0fdf4;
}
&.is-invalid {
border-color: #fecaca;
background: #fef2f2;
}
}
.preflight-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 10px;
color: #111827;
span {
color: #64748b;
font-size: 12px;
}
}
.preflight-section {
margin-top: 10px;
ul {
margin: 6px 0 0;
padding-left: 18px;
color: #374151;
line-height: 1.7;
}
}
.section-label {
display: inline-flex;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.field-tip {
color: #909399;
font-size: 12px;
line-height: 20px;
margin-top: 4px;
}
.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;
&.remote {
margin-top: 6px;
background: #111827;
}
}
.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>