更新前端看板

This commit is contained in:
wangjiming
2026-08-03 16:33:08 +08:00
37 changed files with 1663 additions and 228 deletions

View File

@@ -3,7 +3,8 @@ import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
import { getUsers, type SystemUser } from '@/api/modules/system'
import { getUsers } from '@/api/modules/system'
import type { SystemUser } from '@/types'
const loading = ref(false)
const instances = ref<ApprovalInstance[]>([])
@@ -48,6 +49,10 @@ function openDecide(inst: ApprovalInstance) {
showDecide.value = true
}
function asApprovalInstance(row: unknown): ApprovalInstance {
return row as ApprovalInstance
}
async function submitDecision() {
if (!current.value) return
if (!decision.value.approver_id) {
@@ -72,7 +77,7 @@ onMounted(() => {
<template>
<div class="page">
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable search-fields="resource_type,resource_id">
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
<template #toolbar-extra>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
@@ -82,14 +87,14 @@ onMounted(() => {
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
<el-table-column prop="resource_id" label="资源 ID" min-width="160" show-overflow-tooltip />
<el-table-column prop="applicant_id" label="申请人" min-width="120">
<template #default="{ row }">{{ userName(row.applicant_id) }}</template>
<template #default="{ row }">{{ userName(asApprovalInstance(row).applicant_id) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" />
<el-table-column prop="current_step" label="当前步骤" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
</template>
<template #actions="{ row }">
<el-button v-if="row.status === 'pending'" link type="primary" @click="openDecide(row)">审批</el-button>
<el-button v-if="asApprovalInstance(row).status === 'pending'" link type="primary" @click="openDecide(asApprovalInstance(row))">审批</el-button>
</template>
</DataTablePage>
<el-dialog v-model="showDecide" title="审批决策" width="480px">

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
checkNodeReplicaDrift,
createComputeNode,
deleteComputeNode,
disableComputeNode,
drainComputeNode,
enableComputeNode,
getComputeGpus,
getComputeNodes,
@@ -129,11 +129,10 @@ async function changeTab(name: string | number) {
await router.replace({ path: '/compute', query: { tab: String(name) } })
}
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: ComputeNode) {
async function handleNodeAction(action: 'enable' | 'disable' | 'test', node: ComputeNode) {
const nodeId = String(node.id)
if (action === 'enable') await enableComputeNode(nodeId)
if (action === 'disable') await disableComputeNode(nodeId)
if (action === 'drain') await drainComputeNode(nodeId)
if (action === 'test') {
const result = await testComputeNode(nodeId)
if (result.success) {
@@ -145,6 +144,27 @@ async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test',
await load()
}
async function handleDeleteNode(node: ComputeNode) {
try {
await ElMessageBox.confirm(
`确定删除算力节点「${node.name || node.code}」吗?节点删除后,其 GPU 设备和资源副本记录也会一并移除。`,
'删除算力节点',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await deleteComputeNode(String(node.id))
ElMessage.success('算力节点已删除')
if (selectedNodeId.value === node.id) selectedNodeId.value = ''
await load({ showButtonLoading: true })
} catch (err: any) {
const message = err?.response?.data?.detail?.message || err?.response?.data?.message || '删除算力节点失败'
ElMessage.error(message)
}
}
async function handleReplicaDriftCheck() {
if (!selectedNodeId.value) return
checkingReplicas.value = true
@@ -355,7 +375,7 @@ onUnmounted(() => {
<el-button size="small" @click="handleNodeAction('test', asComputeNode(row))">测试</el-button>
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', asComputeNode(row))">停用</el-button>
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', asComputeNode(row))">启用</el-button>
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', asComputeNode(row))">维护</el-button>
<el-button size="small" type="danger" plain @click="handleDeleteNode(asComputeNode(row))">删除</el-button>
</template>
</el-table-column>
</el-table>

View File

@@ -50,6 +50,20 @@ const rules: FormRules = {
}
/** 处理文件选择(替换模式:新文件覆盖旧文件) */
function parseDatasetRecordValues(text: string, fileName: string): unknown[] {
const content = text.trim()
if (!content) return []
if (fileName.toLowerCase().endsWith('.json')) {
const parsed = JSON.parse(content)
return Array.isArray(parsed) ? parsed : [parsed]
}
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line))
}
async function handleFileChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
@@ -70,25 +84,19 @@ async function handleFileChange(uploadFile: UploadFile) {
async function analyzeFile(file: File) {
try {
const text = await file.text()
const lines = text.trim().split('\n').filter(Boolean)
fileCount.value = lines.length
const records = parseDatasetRecordValues(text, file.name)
fileCount.value = records.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) {
const validCount = records.filter(
(obj) => obj && typeof obj === 'object' && 'instruction' in obj,
).length
if (validCount > 0 && validCount === records.length) {
formatValid.value = true
formatMessage.value = `符合 Alpaca 格式(含 instruction 字段)`
} else if (validCount > 0) {
formatValid.value = true
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${lines.length}`
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${records.length}`
} else {
formatValid.value = false
formatMessage.value = '未检测到标准 Alpaca 格式(缺少 instruction 字段),仍可上传'

View File

@@ -79,7 +79,9 @@ async function loadEditData() {
async function loadModels() {
try {
const all = (await getModelList()) || []
evalModels.value = all.filter((m) => m.purpose === 'evaluation')
evalModels.value = all.filter(
(m) => m.purpose === 'evaluation' || (m.model_source === 'api' && !!m.api_url),
)
} catch {
evalModels.value = []
}

View File

@@ -11,6 +11,7 @@ import { createDimension, startEval } from '@/api/modules/eval'
import { getTrainedModels, getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
type StepExposed = { validate: () => Promise<boolean> }
@@ -84,15 +85,26 @@ async function loadData() {
getDatasetList(),
getSystemInfo(),
getModelList(),
getComputeNodes(),
])
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
if (results[1].status === 'fulfilled') {
evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
}
if (results[2].status === 'fulfilled') gpus.value = results[2].value?.gpu || []
if (results[2].status === 'fulfilled') {
const allGpus: GpuInfo[] = results[2].value?.gpu || []
const nodes: ComputeNode[] = (results[4].status === 'fulfilled' ? results[4].value : []) || []
const onlineIds = new Set(nodes.filter((n) => n.enabled && n.scheduler_status === 'online').map((n) => n.id))
// Only show idle GPUs from online compute nodes
gpus.value = allGpus.filter(
(g) => g.status === 'idle' && (!g.node_id || onlineIds.has(g.node_id)),
)
}
if (results[3].status === 'fulfilled') {
evalModels.value = (results[3].value || []).filter((model) => model.purpose === 'evaluation')
evalModels.value = (results[3].value || []).filter(
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
)
}
const failedCount = results.filter((result) => result.status === 'rejected').length

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { getEvalDetail } from '@/api/modules/eval'
import { usePolling } from '@/composables/usePolling'
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
const route = useRoute()
@@ -16,6 +17,7 @@ const keyword = ref('')
const judgementFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
const filteredSamples = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
@@ -74,8 +76,8 @@ function resetPage() {
currentPage.value = 1
}
async function loadDetail() {
loading.value = true
async function loadDetail(options: { silent?: boolean } = {}) {
if (!options.silent) loading.value = true
loadError.value = ''
try {
detail.value = await getEvalDetail(taskId)
@@ -83,11 +85,29 @@ async function loadDetail() {
detail.value = null
loadError.value = '评测详情加载失败,请稍后重试。'
} finally {
loading.value = false
if (!options.silent) loading.value = false
}
}
onMounted(loadDetail)
const { start: startPolling, stop: stopPolling } = usePolling(
async () => {
await loadDetail({ silent: true })
if (!ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
stopPolling()
}
},
5000,
{ immediate: false },
)
onMounted(async () => {
await loadDetail()
if (ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
startPolling()
}
})
onUnmounted(stopPolling)
</script>
<template>
@@ -113,7 +133,7 @@ onMounted(loadDetail)
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<h2>无法加载评测详情</h2>
<p>{{ loadError }}</p>
<el-button type="primary" @click="loadDetail">重新加载</el-button>
<el-button type="primary" @click="() => loadDetail()">重新加载</el-button>
</div>
<template v-else-if="detail">

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { usePolling } from '@/composables/usePolling'
import {
getEvalList,
deleteEval,
@@ -23,14 +24,16 @@ const leaderboard = ref([
{ rank: 3, name: 'Qwen-Max', score: 85.3 },
])
async function loadEvalList() {
evalLoading.value = true
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
async function loadEvalList(options: { silent?: boolean } = {}) {
if (!options.silent) evalLoading.value = true
try {
evalList.value = (await getEvalList()) || []
} catch {
evalList.value = []
} finally {
evalLoading.value = false
if (!options.silent) evalLoading.value = false
}
}
@@ -54,8 +57,26 @@ function handleViewDetail(row: any) {
router.push({ name: 'model-eval-detail', params: { id: row.id } })
}
onMounted(() => {
loadEvalList()
const { start: startPolling, stop: stopPolling } = usePolling(
async () => {
await loadEvalList({ silent: true })
if (!evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
stopPolling()
}
},
5000,
{ immediate: false },
)
onMounted(async () => {
await loadEvalList()
if (evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
startPolling()
}
})
onUnmounted(() => {
stopPolling()
})
</script>

View File

@@ -56,9 +56,9 @@ defineExpose({ validate })
</el-form-item>
<el-form-item v-if="form.rouge_enabled" label="ROUGE methods">
<el-checkbox-group v-model="form.rouge_methods">
<el-checkbox value="rouge_1">ROUGE-1</el-checkbox>
<el-checkbox value="rouge_2">ROUGE-2</el-checkbox>
<el-checkbox value="rouge_l">ROUGE-L</el-checkbox>
<el-checkbox value="rouge1">ROUGE-1</el-checkbox>
<el-checkbox value="rouge2">ROUGE-2</el-checkbox>
<el-checkbox value="rougeL">ROUGE-L</el-checkbox>
</el-checkbox-group>
</el-form-item>

View File

@@ -103,10 +103,10 @@ defineExpose({ validate })
<el-form-item label="选择 GPU" prop="gpu_id">
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%" :loading="loading">
<el-option
v-for="(gpu, index) in gpus"
:key="index"
:label="`${gpu.name} (GPU ${index})`"
:value="index"
v-for="gpu in gpus"
:key="gpu.id"
:label="`${gpu.name} (GPU ${gpu.id})`"
:value="gpu.id ?? 0"
/>
</el-select>
</el-form-item>

View File

@@ -37,7 +37,7 @@ 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)
const selectedGpuKeys = ref<string[]>([])
/** Only show GPUs from nodes that are online or draining */
const availableGpus = computed(() => {
@@ -74,7 +74,13 @@ const selectedModel = computed(() => models.value.find((model) => model.id === f
const modelDialogTitle = computed(() => selectedModel.value?.name || '')
/** 训练命令与提交载荷共用同一份表单模型。 */
const selectedGpuIds = computed(() => (selectedGpuId.value != null ? [selectedGpuId.value] : []))
const selectedGpus = computed(() =>
selectedGpuKeys.value
.map((key) => availableGpus.value.find((gpu) => gpuKey(gpu) === key))
.filter((gpu): gpu is GpuInfo => Boolean(gpu)),
)
const selectedComputeNodeId = computed(() => selectedGpus.value[0]?.node_id)
const selectedGpuIds = computed(() => selectedGpus.value.map((gpu) => Number(gpu.id)))
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpuIds.value))
const remoteCommandPreview = computed(() => {
@@ -83,9 +89,32 @@ const remoteCommandPreview = computed(() => {
return preflightResult.value?.preview?.command_text || ''
})
/** GPU 单选切换(每次只选中一张 GPU */
function toggleGpu(gpuId: number) {
selectedGpuId.value = selectedGpuId.value === gpuId ? null : gpuId
function gpuKey(gpu: GpuInfo) {
return `${gpu.node_id || 'local'}:${gpu.id ?? gpu.uuid ?? gpu.name}`
}
function isGpuUnavailable(gpu: GpuInfo) {
return gpu.status === 'busy' || gpu.status === 'reserved' || gpu.status === 'offline'
}
function isGpuSelected(gpu: GpuInfo) {
return selectedGpuKeys.value.includes(gpuKey(gpu))
}
/** GPU 多选切换:单个任务只允许选择同一算力节点内的空闲卡。 */
function toggleGpu(gpu: GpuInfo) {
if (isGpuUnavailable(gpu) || gpu.id == null) return
const key = gpuKey(gpu)
if (isGpuSelected(gpu)) {
selectedGpuKeys.value = selectedGpuKeys.value.filter((item) => item !== key)
return
}
if (selectedComputeNodeId.value && gpu.node_id && selectedComputeNodeId.value !== gpu.node_id) {
selectedGpuKeys.value = [key]
ElMessage.info('已切换到新的算力节点,之前选择的 GPU 已清空')
return
}
selectedGpuKeys.value = [...selectedGpuKeys.value, key]
}
function gpuUsageWidth(percent: number) {
@@ -178,8 +207,8 @@ async function loadGpus() {
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
const firstIdle = availableGpus.value.find((gpu) => !isGpuUnavailable(gpu) && gpu.id != null)
if (firstIdle) selectedGpuKeys.value = [gpuKey(firstIdle)]
} catch {
gpus.value = []
}
@@ -189,8 +218,8 @@ async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (selectedGpuId.value == null) {
ElMessage.warning('请选择一 GPU')
if (!selectedGpuIds.value.length) {
ElMessage.warning('请至少选择一张空闲 GPU')
return
}
submitting.value = true
@@ -207,7 +236,7 @@ async function handleSubmit() {
return
}
const payload = buildFineTunePayload(form, selectedGpuIds.value)
const payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)
const preflight = await runPreflight(payload)
if (!preflight?.valid) {
ElMessage.error('训练预检未通过,请先处理预检问题')
@@ -232,7 +261,7 @@ async function handleSubmit() {
})
}
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value)) {
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)) {
preflightLoading.value = true
try {
const result = await preflightFineTune(payload)
@@ -261,8 +290,8 @@ async function handlePreflightClick() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (selectedGpuId.value == null) {
ElMessage.warning('请选择一 GPU')
if (!selectedGpuIds.value.length) {
ElMessage.warning('请至少选择一张空闲 GPU')
return
}
await runPreflight()
@@ -297,12 +326,16 @@ onMounted(() => {
<el-divider content-position="left">训练配置</el-divider>
<el-form-item label="GPU 硬件">
<div class="gpu-list">
<div class="gpu-selection-summary">
已选择 {{ selectedGpuIds.length }} GPU
<template v-if="selectedGpus[0]?.node_code"> · {{ selectedGpus[0].node_code }}</template>
</div>
<div
v-for="gpu in availableGpus"
:key="gpu.id"
:key="gpuKey(gpu)"
class="gpu-card"
:class="{ active: selectedGpuId === gpu.id, 'is-busy': gpu.gpu_percent > 80 }"
@click="toggleGpu(gpu.id!)"
:class="{ active: isGpuSelected(gpu), 'is-busy': isGpuUnavailable(gpu), 'is-disabled': isGpuUnavailable(gpu) }"
@click="toggleGpu(gpu)"
>
<div class="gpu-card-top">
<div class="gpu-title">
@@ -312,7 +345,7 @@ onMounted(() => {
</span>
<span class="gpu-name">{{ gpu.name }}</span>
</div>
<span class="gpu-usage">{{ gpu.gpu_percent }}%</span>
<span class="gpu-usage">{{ isGpuUnavailable(gpu) ? gpu.status : `${gpu.gpu_percent}%` }}</span>
</div>
<div class="gpu-usage-bar">
<span :style="{ width: gpuUsageWidth(gpu.gpu_percent) }" />
@@ -578,6 +611,13 @@ onMounted(() => {
width: 100%;
}
.gpu-selection-summary {
grid-column: 1 / -1;
color: #64748b;
font-size: 12px;
line-height: 20px;
}
.gpu-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
@@ -627,6 +667,11 @@ onMounted(() => {
background: #dc2626;
}
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.72;
}
}
.gpu-card-top {

View File

@@ -65,6 +65,7 @@ export function createDefaultFineTuneForm(): FineTuneFormModel {
export function buildFineTunePayload(
form: FineTuneFormModel,
gpus: number[],
computeNodeId?: string,
): Omit<FineTuneStartPayload, 'task_id'> {
return {
name: form.name,
@@ -77,6 +78,7 @@ export function buildFineTunePayload(
train_dataset_id: form.train_dataset_id,
auto_merge: form.train_type === 'SFT' && form.auto_merge,
output_model_name: form.name,
compute_node_id: computeNodeId,
batch_size: form.batch_size,
learning_rate: form.learning_rate,
n_epochs: form.n_epochs,

View File

@@ -10,8 +10,8 @@ import type { CompareTask, LoadedModel } from '@/types'
const route = useRoute()
const router = useRouter()
const taskId = route.params.id as string
/** 是否为 mock 直通模式(新建推理假数据进入,不走真实任务接口 */
const isMock = taskId === 'mock'
/** 是否为 mock 模式(新建推理无真实 taskId 或明确为 mock 时进入 mock 模式 */
const isMock = taskId === 'mock' || !taskId || taskId === 'unknown'
/** 当前对话使用的模型名 */
const modelName = ref(route.query.model as string || '')
@@ -88,30 +88,20 @@ async function handleSend() {
return
}
// 真实模式:获取已启动模型的端口/路径
const models = parseLoadedModels(task.value)
const target = models[0]
if (!target) {
ElMessage.error('未找到已启动的模型')
assistantMsg.content = '未找到已启动的模型,请先返回列表加载模型'
assistantMsg.done = true
assistantMsg.isStreaming = false
return
}
// 流式状态变化时只同步当前回复,避免固定定时器空转。
// 真实模式:通过后端 SSE 流式代理到算力节点进行推理
activeAssistant = assistantMsg
await send({
port: target.port,
model_name: target.model_name,
model_path: '',
system_prompt: systemPrompt.value,
user_question: question,
temperature: temperature.value,
top_p: top_p.value,
max_tokens: maxTokens.value,
})
await send(
{
model_path: route.query.model_path as string || '',
system_prompt: systemPrompt.value,
user_question: question,
temperature: temperature.value,
top_p: top_p.value,
max_tokens: maxTokens.value,
},
{ useMock: false },
)
// 完成后同步最终内容
assistantMsg.content = message.value.displayContent || message.value.error || '(无回复)'

View File

@@ -5,6 +5,8 @@ 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 { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
import { createCompare, preloadLocalModel, preloadTrainedModel } from '@/api/modules/compare'
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
const router = useRouter()
@@ -15,6 +17,7 @@ const startupStatus = ref('')
const dbModels = ref<ModelItem[]>([])
const trainedModels = ref<TrainedModel[]>([])
const gpus = ref<GpuInfo[]>([])
const computeNodes = ref<ComputeNode[]>([])
/** 可选模型(下拉用,区分本地/已训练两类) */
interface SelectableModel {
@@ -54,7 +57,17 @@ const trainedOptions = computed<SelectableModel[]>(() =>
})),
)
/** key → 模型映射,便于取选中项 */
/** 仅显示在线算力节点上的空闲 GPU */
const onlineNodeIds = computed(() => new Set(
computeNodes.value
.filter((n) => n.enabled && n.scheduler_status === 'online')
.map((n) => n.id),
))
const idleGpus = computed(() =>
gpus.value.filter(
(g) => g.status === 'idle' && (!g.node_id || onlineNodeIds.value.has(g.node_id)),
),
)
const modelMap = computed<Record<string, SelectableModel>>(() => {
const map: Record<string, SelectableModel> = {}
for (const m of [...dbOptions.value, ...trainedOptions.value]) map[m.key] = m
@@ -90,12 +103,56 @@ async function handleSubmit() {
submitting.value = true
startupStatus.value = '正在启动模型服务...'
try {
// 当前为 mock 环境:不创建任务、不启动后端服务,
// 用假数据直通进入对话界面(模型名通过 query 传递)。
// 接入真实后端后,可在此恢复 createCompare / startModelsInBackground / monitorStartup 流程。
await new Promise((resolve) => setTimeout(resolve, 1200))
// Step 1: 将模型加载到算力节点
const preloadPayload = {
model_name_or_path: m.model_path,
model_name: m.name,
template: 'qwen',
}
let preloadResult: any
if (m.source === 'trained') {
preloadResult = await preloadTrainedModel(preloadPayload)
} else {
preloadResult = await preloadLocalModel(preloadPayload)
}
if (preloadResult && (preloadResult as any).error) {
ElMessage.warning(`模型加载失败:${(preloadResult as any).error}`)
submitting.value = false
startupStatus.value = ''
return
}
// Step 2: 创建推理任务记录
const taskResult = await createCompare({
name: form.name || m.name,
description: form.description,
models: [
{
model_id: String(m.id),
model_name: m.name,
model_path: m.model_path,
source: m.source,
gpu_id: form.gpu_id,
},
],
})
const taskId = taskResult?.id || 'unknown'
ElMessage.success('模型已启动')
router.push({
path: `/model-inference/chat/${taskId}`,
query: {
model: m.name,
source: m.source,
model_path: m.model_path,
},
})
} catch (e: any) {
// 真实 API 失败时回退到 mock 模式(方便无算力节点的开发调试)
const m = selectedModel.value!
const reason = e?.message || e?.toString() || '未知错误'
ElMessage.warning(`推理服务启动失败:${reason},进入 mock 演示模式`)
router.push({
path: '/model-inference/chat/mock',
query: { model: m.name },
@@ -113,16 +170,18 @@ function handleCancel() {
async function loadData() {
try {
const [db, trained, sys] = await Promise.all([
const [db, trained, sys, nodes] = await Promise.all([
getModelList(),
getTrainedModels(),
getSystemInfo(),
getComputeNodes(),
])
dbModels.value = db || []
trainedModels.value = trained?.models || []
gpus.value = sys?.gpu || []
// 默认选中第一个 GPU
if (gpus.value.length > 0) form.gpu_id = 0
computeNodes.value = nodes || []
// 默认选中第一个空闲 GPU
if (idleGpus.value.length > 0) form.gpu_id = idleGpus.value[0].id ?? 0
} catch {
// ignore
}
@@ -172,10 +231,10 @@ onMounted(loadData)
<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"
v-for="g in idleGpus"
:key="g.id ?? 0"
:label="`${g.name} (GPU${g.id ?? 0}) [空闲]`"
:value="g.id ?? 0"
/>
</el-select>
</el-form-item>

View File

@@ -7,10 +7,8 @@ import { usePolling } from '@/composables/usePolling'
import {
getCompareList,
deleteCompare,
getCompare,
loadCompare,
unloadCompare,
stopModelByPid,
} from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
import { statusLabel, statusTagType } from '@/utils/status'
@@ -86,26 +84,19 @@ async function handleLoad(row: any) {
delayedRefreshTimer = setTimeout(loadData, 1000)
}
/** 卸载推理任务 */
/** 释放推理任务(停止模型服务,释放算力节点 GPU 显存) */
async function handleUnload(row: any) {
await ElMessageBox.confirm('确定要停止模型服务吗?', '确认停止', { type: 'warning' })
await ElMessageBox.confirm('确定要释放模型服务吗?将停止模型进程并释放 GPU 显存。', '确认释放', { type: 'warning' })
await unloadCompare(row.id)
ElMessage.success('已停止模型服务')
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 ElMessageBox.confirm('确定要删除该推理记录吗?将先释放算力节点再删除。', '确认删除', { type: 'warning' })
// 先释放算力节点上的模型
await unloadCompare(row.id).catch(() => {})
await deleteCompare(row.id)
dataList.value = dataList.value.filter((item) => item.id !== row.id)
await loadData(true)
@@ -180,7 +171,7 @@ onUnmounted(() => {
<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" />停止
<i class="fa fa-stop-circle-o" style="margin-right: 4px" />释放
</el-button>
</template>
<template v-else>

View File

@@ -5,7 +5,8 @@ import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import AclDialog from '@/components/AclDialog.vue'
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
import { getUsers, type SystemUser } from '@/api/modules/system'
import { getUsers } from '@/api/modules/system'
import type { SystemUser } from '@/types'
const route = useRoute()
const router = useRouter()
@@ -56,6 +57,10 @@ async function removeMember(userId: string) {
load()
}
function asProjectMember(row: unknown): ProjectMember {
return row as ProjectMember
}
onMounted(() => {
loadUsers()
load()
@@ -94,7 +99,7 @@ onMounted(() => {
<el-table-column prop="create_time" label="加入时间" min-width="180" />
</template>
<template #actions="{ row }">
<el-button link type="danger" @click="removeMember(row.user_id)">移除</el-button>
<el-button link type="danger" @click="removeMember(asProjectMember(row).user_id)">移除</el-button>
</template>
</DataTablePage>
</el-card>

View File

@@ -52,6 +52,10 @@ function openDetail(id: string) {
router.push(`/projects/${id}`)
}
function asProject(row: unknown): Project {
return row as Project
}
async function submitCreate() {
if (!form.value.name || !form.value.tenant_id) {
ElMessage.warning('请填写项目名与编码ID')
@@ -87,7 +91,7 @@ onMounted(() => {
<template>
<div class="page">
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable search-fields="name,code">
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable :search-fields="['name', 'code']">
<template #toolbar-extra>
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />

View File

@@ -54,6 +54,18 @@ function isSelf(row: SystemUser) {
return row.username === currentUsername.value
}
function asSystemUser(row: unknown): SystemUser {
return row as SystemUser
}
function userPermissions(row: unknown): PermissionCode[] {
return (asSystemUser(row).permissions || []) as PermissionCode[]
}
function permissionLabel(code: PermissionCode): string {
return PERMISSION_LABELS[code] || code
}
// ---------- 启停 ----------
async function toggleStatus(row: SystemUser, next: boolean) {
const nextStatus: UserStatus = next ? 'active' : 'disabled'
@@ -162,31 +174,31 @@ async function removeUser(row: SystemUser) {
<el-table-column prop="role" label="角色" width="120" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
<el-tag :type="statusTagType(asSystemUser(row).status)" size="small">{{ statusLabel(asSystemUser(row).status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="页面权限" min-width="160">
<template #default="{ row }">
<el-tag
v-for="p in (row.permissions || []).slice(0, 3)"
v-for="p in userPermissions(row).slice(0, 3)"
:key="p"
size="small"
type="info"
class="perm-tag"
>{{ PERMISSION_LABELS[p] || p }}</el-tag>
<span v-if="(row.permissions || []).length > 3" class="perm-more">
+{{ (row.permissions || []).length - 3 }}
>{{ permissionLabel(p) }}</el-tag>
<span v-if="userPermissions(row).length > 3" class="perm-more">
+{{ userPermissions(row).length - 3 }}
</span>
<span v-if="!(row.permissions || []).length" class="perm-more"></span>
<span v-if="!userPermissions(row).length" class="perm-more"></span>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-switch
:model-value="row.status === 'active'"
:disabled="row.protected || isSelf(row)"
@change="(v: any) => toggleStatus(row, v)"
:model-value="asSystemUser(row).status === 'active'"
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
@change="(v: any) => toggleStatus(asSystemUser(row), v)"
inline-prompt
active-text="启用"
inactive-text="停用"
@@ -194,19 +206,19 @@ async function removeUser(row: SystemUser) {
<el-button
link
type="primary"
:disabled="row.protected"
@click="openResetPwd(row)"
:disabled="asSystemUser(row).protected"
@click="openResetPwd(asSystemUser(row))"
>重置密码</el-button>
<el-button
link
type="primary"
@click="openPerms(row)"
@click="openPerms(asSystemUser(row))"
>页面权限</el-button>
<el-button
link
type="danger"
:disabled="row.protected || isSelf(row)"
@click="removeUser(row)"
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
@click="removeUser(asSystemUser(row))"
>删除</el-button>
</template>
</el-table-column>

View File

@@ -46,6 +46,15 @@ function openDetail(id: string) {
router.push(`/tenants/${id}`)
}
function asTenant(row: unknown): Tenant {
return row as Tenant
}
function quotaText(row: unknown): string {
const quota = asTenant(row).quota || {}
return Object.keys(quota).length ? JSON.stringify(quota) : '—'
}
async function submitCreate() {
if (!form.value.name) {
ElMessage.warning('请填写租户名称')