feat: 更新后端平台模块、Compute引擎、前端组件及构建产物

- 更新 backend 平台 API、platform_store、compute_gateway sync
- 更新 compute agent/engine/adapter 及 API
- 更新 Docker 部署配置(app/compute)
- 新增 frontend/src/utils/ 工具模块
- 新增 scripts/ops_diagnostics.py 运维诊断脚本
- 新增 docs/2026-07-23-development-summary.md 开发总结
- 重构 frontend/dist 构建产物(新 hash)
- 更新前端多个视图组件及 API 模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-23 19:32:42 +08:00
parent f04dc479bb
commit b28cfbc6fa
193 changed files with 2647 additions and 424 deletions

View File

@@ -70,6 +70,10 @@ export interface ResourceReplica {
local_path: string
status: string
sync_status: string
checksum_sha256?: string
byte_size?: number
last_checked_at?: string
last_error?: string
create_time: string
}
@@ -95,3 +99,12 @@ export const getComputeGpus = () => get<ComputeGpu[]>('/compute/gpus')
export const getComputeQueue = () => get<ComputeQueueItem[]>('/compute/queue')
export const getNodeReplicas = (id: string) => get<ResourceReplica[]>(`/compute/nodes/${id}/replicas`)
export const checkNodeReplicaDrift = (id: string) =>
get<{ node_id: string; items: ResourceReplica[]; drifted: number }>(`/compute/nodes/${id}/replicas/drift`)
export const repairNodeReplicas = (id: string, replicaIds?: string[]) =>
post<{ sync: unknown; replicas: ResourceReplica[]; failed: Array<{ replica_id: string; error: string }> }>(
`/compute/nodes/${id}/replicas/repair`,
replicaIds?.length ? { replica_ids: replicaIds } : {},
)

View File

@@ -1,6 +1,45 @@
import { get, post, put, del } from '../request'
import type { ModelItem, ModelForm, TrainedModel } from '@/types'
export interface ModelArtifact {
id: string
model_id: string
model_kind: string
artifact_type: string
path: string
size_bytes: number
checksum_sha256?: string
metadata?: Record<string, unknown>
compute_job_id?: string
create_time?: string
}
export interface ModelLineageEdge {
id: string
child_resource_type: string
child_resource_id: string
parent_resource_type: string
parent_resource_id: string
relation_type: string
compute_job_id?: string
payload?: Record<string, unknown>
create_time?: string
}
export interface ModelExportJob {
id: string
trained_model_id?: string
compute_job_id: string
node_id?: string
export_type: string
quantization_bit: number
status: string
output_dir?: string
payload?: Record<string, unknown>
create_time?: string
completed_at?: string
}
/** 模型列表 */
export const getModelList = () => get<ModelItem[]>('/model-manage')
@@ -18,6 +57,17 @@ export const getLocalModels = () =>
export const getTrainedModels = () =>
get<{ models: TrainedModel[] }>('/model-manage/trained-models')
export const getTrainedModelArtifacts = (id: string | number) =>
get<ModelArtifact[]>(`/model-manage/trained-models/${id}/artifacts`)
export const getTrainedModelLineage = (id: string | number) =>
get<{ model_id: string; parents: ModelLineageEdge[]; children: ModelLineageEdge[] }>(
`/model-manage/trained-models/${id}/lineage`,
)
export const getModelExportJobs = (trainedModelId?: string | number) =>
get<ModelExportJob[]>('/model-manage/export-jobs', trainedModelId ? { trained_model_id: trainedModelId } : undefined)
/** 创建模型 */
export const createModel = (data: ModelForm) => post('/model-manage', data)

View File

@@ -48,6 +48,7 @@ const props = withDefaults(
const emit = defineEmits<{
create: []
refresh: []
expandChange: [row: T, expandedRows: T[]]
}>()
const router = useRouter()
@@ -143,6 +144,10 @@ function handleCreate() {
}
}
function handleExpandChange(row: T, expandedRows: T[] | boolean) {
emit('expandChange', row, Array.isArray(expandedRows) ? expandedRows : [])
}
// 数据变化时清空选中
watch(
() => props.data,
@@ -214,6 +219,7 @@ defineExpose({ handleDelete, clearSelection })
style="width: 100%"
:empty-text="emptyText"
@selection-change="handleSelectionChange"
@expand-change="handleExpandChange"
>
<el-table-column
v-if="multiSelect"

View File

@@ -1,33 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusLabel, statusTagType } from '@/utils/status'
const props = defineProps<{
status?: string
}>()
/** 将任务/模型状态映射为 Element Plus tag 类型 */
const tagInfo = computed(() => {
const s = props.status || ''
switch (s) {
case 'running':
case 'ready':
case 'loaded':
return { text: s === 'ready' || s === 'loaded' ? '已就绪' : '运行中', type: 'success' as const }
case 'pending':
return { text: '等待中', type: 'info' as const }
case 'failed':
return { text: '失败', type: 'danger' as const }
case 'starting':
case 'loading':
return { text: '加载中', type: 'warning' as const }
case 'completed':
return { text: '已完成', type: 'success' as const }
case 'stopped':
return { text: '已停止', type: 'info' as const }
default:
return { text: s || '未知', type: 'info' as const }
}
})
const tagInfo = computed(() => ({
text: statusLabel(props.status),
type: statusTagType(props.status),
}))
</script>
<template>

View File

@@ -0,0 +1,99 @@
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
syncing: '同步中',
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
stopped: '已停止',
cancelled: '已取消',
starting: '启动中',
loading: '加载中',
loaded: '已加载',
ready: '已就绪',
done: '已完成',
error: '异常',
success: '成功',
not_started: '未启动',
valid: '有效',
modified: '已修改',
invalid: '无效',
original: '原始',
manual: '手动新增',
active: '启用',
disabled: '停用',
online: '在线',
offline: '离线',
draining: '维护中',
maintenance: '维护模式',
busy: '忙碌',
reserved: '已预留',
idle: '空闲',
warning: '告警',
available: '可用',
missing: '缺失',
synced: '已同步',
drifted: '已漂移',
repair_pending: '待修复',
}
const STATUS_TYPES: Record<string, TagType> = {
completed: 'success',
success: 'success',
running: 'success',
ready: 'success',
loaded: 'success',
active: 'success',
online: 'success',
busy: 'success',
available: 'success',
synced: 'success',
valid: 'success',
pending: 'info',
idle: 'info',
offline: 'info',
disabled: 'info',
original: 'info',
stopped: 'info',
cancelled: 'info',
syncing: 'warning',
queued: 'warning',
starting: 'warning',
loading: 'warning',
reserved: 'warning',
warning: 'warning',
modified: 'warning',
draining: 'warning',
maintenance: 'warning',
repair_pending: 'warning',
failed: 'danger',
error: 'danger',
invalid: 'danger',
missing: 'danger',
drifted: 'danger',
}
export function statusLabel(status?: string | number | null) {
const key = String(status ?? '').trim()
if (!key) return '未知'
return STATUS_LABELS[key] || key
}
export function statusTagType(status?: string | number | null): TagType {
const key = String(status ?? '').trim()
return STATUS_TYPES[key] || 'info'
}
export function mergeStatusLabel(row: { merged?: boolean; merging?: boolean }) {
if (row.merging) return '合并中'
if (row.merged) return '已合并'
return '未合并'
}
export function mergeStatusType(row: { merged?: boolean; merging?: boolean }): TagType {
if (row.merging) return 'warning'
if (row.merged) return 'success'
return 'info'
}

View File

@@ -6,6 +6,7 @@ import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import { getCompare } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
import { statusLabel } from '@/utils/status'
const route = useRoute()
const router = useRouter()
@@ -92,7 +93,7 @@ onMounted(async () => {
:type="m.status === 'ready' || m.status === 'running' ? 'success' : m.status === 'starting' ? 'warning' : 'danger'"
size="large"
>
{{ m.model_name }} ({{ m.status }})
{{ m.model_name }} ({{ statusLabel(m.status) }})
</el-tag>
<span v-if="!loadedModels.length" class="empty-hint">暂无已启动模型</span>
</div>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
checkNodeReplicaDrift,
createComputeNode,
disableComputeNode,
drainComputeNode,
@@ -11,6 +12,7 @@ import {
getComputeNodes,
getComputeQueue,
getNodeReplicas,
repairNodeReplicas,
testComputeNode,
updateComputeNode,
type ComputeGpu,
@@ -18,6 +20,7 @@ import {
type ComputeQueueItem,
type ResourceReplica,
} from '@/api/modules/compute'
import { statusLabel, statusTagType } from '@/utils/status'
const route = useRoute()
const router = useRouter()
@@ -25,6 +28,8 @@ const router = useRouter()
const activeTab = ref(String(route.query.tab || 'nodes'))
const loading = ref(true)
const buttonRefreshing = ref(false)
const checkingReplicas = ref(false)
const repairingReplicas = ref(false)
const nodes = ref<ComputeNode[]>([])
const gpus = ref<ComputeGpu[]>([])
const queue = ref<ComputeQueueItem[]>([])
@@ -58,6 +63,10 @@ const enabledNodes = computed(() => nodes.value.filter((item) => item.enabled).l
const busyGpus = computed(() => gpus.value.filter((item) => item.status === 'busy' || item.status === 'reserved').length)
const totalRunningJobs = computed(() => nodes.value.reduce((sum, item) => sum + item.current_running_jobs, 0))
function asComputeNode(row: unknown): ComputeNode {
return row as ComputeNode
}
watch(
() => route.query.tab,
(tab) => {
@@ -68,10 +77,8 @@ watch(
async function load(options: { showLoading?: boolean; showButtonLoading?: boolean } = {}) {
if (refreshInFlight) return
refreshInFlight = true
const showLoading = options.showLoading ?? false
const showButtonLoading = options.showButtonLoading ?? false
if (showLoading) loading.value = true
if (showButtonLoading) buttonRefreshing.value = true
if (options.showLoading) loading.value = true
if (options.showButtonLoading) buttonRefreshing.value = true
try {
const [nodeList, gpuList, queueList] = await Promise.all([
getComputeNodes(),
@@ -87,8 +94,8 @@ async function load(options: { showLoading?: boolean; showButtonLoading?: boolea
await loadReplicas()
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} finally {
if (showLoading) loading.value = false
if (showButtonLoading) buttonRefreshing.value = false
if (options.showLoading) loading.value = false
if (options.showButtonLoading) buttonRefreshing.value = false
refreshInFlight = false
}
}
@@ -105,7 +112,7 @@ async function changeTab(name: string | number) {
await router.replace({ path: '/compute', query: { tab: String(name) } })
}
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: any) {
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: ComputeNode) {
const nodeId = String(node.id)
if (action === 'enable') await enableComputeNode(nodeId)
if (action === 'disable') await disableComputeNode(nodeId)
@@ -121,6 +128,40 @@ async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test',
await load()
}
async function handleReplicaDriftCheck() {
if (!selectedNodeId.value) return
checkingReplicas.value = true
try {
const result = await checkNodeReplicaDrift(selectedNodeId.value)
replicas.value = result.items || []
ElMessage.success(result.drifted ? `检测完成,发现 ${result.drifted} 个异常副本` : '检测完成,副本状态正常')
} finally {
checkingReplicas.value = false
}
}
async function handleReplicaRepair() {
if (!selectedNodeId.value) return
const targets = replicas.value.filter((item) => ['drifted', 'failed', 'repair_pending'].includes(item.sync_status))
if (!targets.length) {
ElMessage.info('暂无需要修复的副本')
return
}
repairingReplicas.value = true
try {
const result = await repairNodeReplicas(selectedNodeId.value, targets.map((item) => item.id))
replicas.value = result.replicas || []
if (result.failed?.length) {
ElMessage.warning(`修复完成,失败 ${result.failed.length}`)
} else {
ElMessage.success('副本修复完成')
}
await loadReplicas()
} finally {
repairingReplicas.value = false
}
}
function resetNodeForm() {
Object.assign(nodeForm, {
id: '',
@@ -146,7 +187,7 @@ function openCreateNodeDialog() {
nodeDialogVisible.value = true
}
function openEditNodeDialog(node: any) {
function openEditNodeDialog(node: ComputeNode) {
Object.assign(nodeForm, {
id: node.id,
code: node.code,
@@ -212,23 +253,17 @@ async function saveNode() {
}
}
function nodeStatusType(status: string) {
if (status === 'online') return 'success'
if (status === 'draining' || status === 'maintenance') return 'warning'
return 'info'
function formatBytes(value?: number) {
const size = Number(value || 0)
if (!size) return '-'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function taskStatusType(status: string) {
if (status === 'running') return 'success'
if (status === 'syncing' || status === 'queued') return 'warning'
if (status === 'failed') return 'danger'
return 'info'
}
function gpuStatusType(status: string) {
if (status === 'busy') return 'success'
if (status === 'reserved') return 'warning'
return 'info'
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(() => {
@@ -256,22 +291,10 @@ onUnmounted(() => {
</header>
<div class="summary-grid">
<div class="summary-tile">
<span>在线节点</span>
<strong>{{ enabledNodes }} / {{ nodes.length }}</strong>
</div>
<div class="summary-tile">
<span>GPU 占用</span>
<strong>{{ busyGpus }} / {{ gpus.length }}</strong>
</div>
<div class="summary-tile">
<span>运行任务</span>
<strong>{{ totalRunningJobs }}</strong>
</div>
<div class="summary-tile">
<span>队列任务</span>
<strong>{{ queue.length }}</strong>
</div>
<div class="summary-tile"><span>启用节点</span><strong>{{ enabledNodes }} / {{ nodes.length }}</strong></div>
<div class="summary-tile"><span>GPU 占用</span><strong>{{ busyGpus }} / {{ gpus.length }}</strong></div>
<div class="summary-tile"><span>运行任务</span><strong>{{ totalRunningJobs }}</strong></div>
<div class="summary-tile"><span>队列任务</span><strong>{{ queue.length }}</strong></div>
</div>
<el-tabs v-model="activeTab" class="compute-tabs" @tab-change="changeTab">
@@ -281,7 +304,7 @@ onUnmounted(() => {
<el-table-column prop="name" label="节点名称" min-width="150" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="nodeStatusType(row.scheduler_status)">{{ row.scheduler_status }}</el-tag>
<el-tag :type="statusTagType(row.scheduler_status)">{{ statusLabel(row.scheduler_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
@@ -308,11 +331,11 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="330" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditNodeDialog(row)">编辑</el-button>
<el-button size="small" @click="handleNodeAction('test', row)">测试</el-button>
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', row)">停用</el-button>
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', row)">启用</el-button>
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', row)">维护</el-button>
<el-button size="small" @click="openEditNodeDialog(asComputeNode(row))">编辑</el-button>
<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>
</template>
</el-table-column>
</el-table>
@@ -328,13 +351,11 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="gpuStatusType(row.status)">{{ row.status }}</el-tag>
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="利用率" min-width="180">
<template #default="{ row }">
<el-progress :percentage="row.gpu_percent" :stroke-width="8" />
</template>
<template #default="{ row }"><el-progress :percentage="row.gpu_percent" :stroke-width="8" /></template>
</el-table-column>
<el-table-column label="显存" min-width="190">
<template #default="{ row }">
@@ -361,19 +382,19 @@ onUnmounted(() => {
<el-table-column prop="name" label="任务名称" min-width="200" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)">{{ row.status }}</el-tag>
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="220">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="8" />
</template>
<template #default="{ row }"><el-progress :percentage="row.progress" :stroke-width="8" /></template>
</el-table-column>
<el-table-column prop="compute_node_id" label="节点" width="140" />
<el-table-column label="GPU" width="120">
<template #default="{ row }">{{ row.gpus?.join(', ') || '-' }}</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="190" />
<el-table-column label="创建时间" width="190">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
@@ -383,14 +404,29 @@ onUnmounted(() => {
<el-option v-for="node in nodes" :key="node.id" :label="`${node.code} - ${node.name}`" :value="node.id" />
</el-select>
<span v-if="selectedNode" class="muted">当前节点 {{ selectedNode.api_base_url }}</span>
<el-button :loading="checkingReplicas" @click="handleReplicaDriftCheck">漂移检测</el-button>
<el-button type="primary" :loading="repairingReplicas" @click="handleReplicaRepair">修复副本</el-button>
</div>
<el-table :data="replicas" height="100%">
<el-table-column prop="resource_type" label="资源类型" width="110" />
<el-table-column prop="resource_id" label="资源 ID" min-width="180" />
<el-table-column prop="local_path" label="本地路径" min-width="300" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="sync_status" label="同步状态" width="120" />
<el-table-column prop="create_time" label="创建时间" width="190" />
<el-table-column prop="local_path" label="本地路径" min-width="300" show-overflow-tooltip />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="同步状态" width="120">
<template #default="{ row }">
<el-tag :type="statusTagType(row.sync_status)" size="small">{{ statusLabel(row.sync_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="110">
<template #default="{ row }">{{ formatBytes(row.byte_size) }}</template>
</el-table-column>
<el-table-column label="最后检测" width="190">
<template #default="{ row }">{{ formatTime(row.last_checked_at) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
@@ -432,21 +468,11 @@ onUnmounted(() => {
</el-select>
</el-form-item>
</div>
<el-form-item label="标签">
<el-input v-model="nodeForm.tags_text" placeholder="a800, lora, beijing" />
</el-form-item>
<el-form-item label="数据根目录">
<el-input v-model="nodeForm.data_root" />
</el-form-item>
<el-form-item label="模型根目录">
<el-input v-model="nodeForm.model_root" />
</el-form-item>
<el-form-item label="训练日志目录">
<el-input v-model="nodeForm.log_root" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="nodeForm.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="标签"><el-input v-model="nodeForm.tags_text" placeholder="a800, lora, beijing" /></el-form-item>
<el-form-item label="数据根目录"><el-input v-model="nodeForm.data_root" /></el-form-item>
<el-form-item label="模型根目录"><el-input v-model="nodeForm.model_root" /></el-form-item>
<el-form-item label="训练日志目录"><el-input v-model="nodeForm.log_root" /></el-form-item>
<el-form-item label="备注"><el-input v-model="nodeForm.description" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="nodeDialogVisible = false">取消</el-button>
@@ -486,7 +512,8 @@ onUnmounted(() => {
}
}
.header-actions {
.header-actions,
.replica-toolbar {
display: flex;
align-items: center;
gap: 12px;
@@ -499,7 +526,6 @@ onUnmounted(() => {
}
.last-updated {
display: inline-block;
min-width: 92px;
text-align: right;
}
@@ -549,9 +575,6 @@ onUnmounted(() => {
}
.replica-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
.el-select {

View File

@@ -3,6 +3,7 @@ import { ref, onMounted } 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 {
getEvalList,
deleteEval,
@@ -84,7 +85,11 @@ onMounted(() => {
<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="状态" width="100" align="center">
<template #default="{ row }">
<ModelStatusTag :status="row.status" />
</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') : '-' }}

View File

@@ -99,7 +99,7 @@ export function buildFineTunePayload(
export function buildFineTuneCommand(form: FineTuneFormModel, gpus: number[]) {
const gpuIds = gpus.length ? gpus.join(',') : '0'
const stage = form.train_type === 'DPO' ? 'dpo' : form.train_type === 'CPT' ? 'cpt' : 'sft'
const stage = form.train_type === 'DPO' ? 'dpo' : form.train_type === 'CPT' ? 'pt' : 'sft'
const lines = [
`CUDA_VISIBLE_DEVICES=${gpuIds} llamafactory-cli train`,
` --stage ${stage}`,

View File

@@ -13,6 +13,7 @@ import {
stopModelByPid,
} from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
import { statusLabel, statusTagType } from '@/utils/status'
const router = useRouter()
@@ -152,7 +153,7 @@ onUnmounted(() => {
<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>
<el-tag v-else :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status || 'not_started') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="相关模型" align="center" show-overflow-tooltip>

View File

@@ -1,36 +1,101 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onMounted, reactive, ref, 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,
getModelExportJobs,
getModelList,
getTrainedModelArtifacts,
getTrainedModelLineage,
getTrainedModels,
type ModelArtifact,
type ModelExportJob,
type ModelLineageEdge,
} from '@/api/modules/model'
import { MODEL_TYPE_MAP, PURPOSE_MAP, MODEL_SOURCE_MAP } from '@/constants'
import { MODEL_SOURCE_MAP, MODEL_TYPE_MAP, PURPOSE_MAP } from '@/constants'
import type { ModelItem, TrainedModel } from '@/types'
import { mergeStatusLabel, mergeStatusType, statusLabel, statusTagType } from '@/utils/status'
const router = useRouter()
type TabKey = 'config' | 'trained'
const activeTab = ref<TabKey>('config')
type TrainedModelRuntime = {
artifacts: ModelArtifact[]
lineage: { parents: ModelLineageEdge[]; children: ModelLineageEdge[] }
exportJobs: ModelExportJob[]
loading: boolean
loaded: boolean
}
const activeTab = ref<TabKey>('config')
const loading = ref(false)
const configList = ref<ModelItem[]>([])
const trainedList = ref<TrainedModel[]>([])
const runtimeMap = reactive<Record<string, TrainedModelRuntime>>({})
function asModelItem(row: unknown): ModelItem {
return row as ModelItem
}
function asTrainedModel(row: unknown): TrainedModel {
return row as TrainedModel
}
function runtimeKey(row: TrainedModel) {
return String(row.id || row.name)
}
function runtimeOf(row: TrainedModel) {
const key = runtimeKey(row)
if (!runtimeMap[key]) {
runtimeMap[key] = {
artifacts: [],
lineage: { parents: [], children: [] },
exportJobs: [],
loading: false,
loaded: false,
}
}
return runtimeMap[key]
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
function formatBytes(value?: number) {
const size = Number(value || 0)
if (size <= 0) return '-'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function relationLabel(value?: string) {
const map: Record<string, string> = {
fine_tuned_from: '由基座模型微调生成',
merged_from_adapter: '由 Adapter 合并导出',
}
return map[value || ''] || value || '-'
}
function artifactTypeLabel(value?: string) {
const map: Record<string, string> = {
adapter: 'Adapter 权重',
merged_model: '合并模型',
quantized_model: '量化模型',
}
return map[value || ''] || value || '-'
}
async function loadConfig() {
loading.value = true
try {
configList.value = (await getModelList()) || []
} catch {
// ignore
} finally {
loading.value = false
}
@@ -41,8 +106,6 @@ async function loadTrained() {
try {
const res = await getTrainedModels()
trainedList.value = res?.models || []
} catch {
// ignore
} finally {
loading.value = false
}
@@ -53,32 +116,54 @@ function loadData() {
else loadTrained()
}
/** 删除配置模型 */
async function handleDeleteConfig(row: any) {
async function loadTrainedRuntime(row: TrainedModel, force = false) {
const state = runtimeOf(row)
if (state.loading || (state.loaded && !force)) return
state.loading = true
try {
const id = row.id || row.name
const [artifacts, lineage, exportJobs] = await Promise.all([
getTrainedModelArtifacts(id),
getTrainedModelLineage(id),
getModelExportJobs(id),
])
state.artifacts = artifacts || []
state.lineage = {
parents: lineage?.parents || [],
children: lineage?.children || [],
}
state.exportJobs = exportJobs || []
state.loaded = true
} finally {
state.loading = false
}
}
function handleExpandChange(row: TrainedModel, expandedRows: TrainedModel[]) {
if (expandedRows.some((item) => runtimeKey(item) === runtimeKey(row))) {
loadTrainedRuntime(row)
}
}
async function handleDeleteConfig(row: ModelItem) {
await deleteModel(row.id)
configList.value = configList.value.filter((item) => item.id !== row.id)
await loadConfig()
ElMessage.success('删除成功')
}
/** 删除 LoRA 权重 */
async function handleDeleteWeight(row: any) {
await ElMessageBox.confirm(
`确定要删除模型 "${row.name}" 的权重文件吗?合并模型不受影响。`,
'确认删除',
{ type: 'warning' },
)
await deleteTrainedModel(row.name, 'lora')
async function handleDeleteWeight(row: TrainedModel) {
await ElMessageBox.confirm(`确定要删除模型“${row.name}”的权重文件吗?合并模型不受影响。`, '确认删除', {
type: 'warning',
})
await deleteTrainedModel(row.id || row.name, 'lora')
trainedList.value = trainedList.value.filter((item) => item.name !== row.name && item.id !== row.id)
await loadTrained()
ElMessage.success('权重已删除')
}
/** 合并权重 */
async function handleMerge(row: any) {
await router.push({
function handleMerge(row: TrainedModel) {
router.push({
path: '/model-manage/merge',
query: {
model: row.name,
@@ -87,12 +172,7 @@ async function handleMerge(row: any) {
})
}
/** 导出 */
function handleExport(row: any) {
window.open(exportModelUrl(row.name), '_blank')
}
function editModel(row: any) {
function editModel(row: ModelItem) {
router.push(`/model-manage/${row.id}/edit`)
}
@@ -103,34 +183,28 @@ function handleCreateClick() {
}
function handleRefresh() {
if (activeTab.value === 'config') {
loadConfig()
} else {
loadTrained()
}
if (activeTab.value === 'config') loadConfig()
else loadTrained()
}
watch(activeTab, () => {
loadData()
})
watch(activeTab, loadData)
onMounted(loadData)
</script>
<template>
<div class="model-manage-page" style="height: 100%;">
<!-- 配置模型列表 -->
<div class="model-manage-page">
<DataTablePage
v-if="activeTab === 'config'"
title=""
title="模型"
:data="configList"
:loading="loading"
searchable
:search-fields="['name', 'description']"
create-text="添加模型"
@create="handleCreateClick"
:delete-fn="handleDeleteConfig"
row-key="id"
@create="handleCreateClick"
@refresh="loadConfig"
>
<template #title>
@@ -140,55 +214,49 @@ onMounted(loadData)
</div>
</template>
<template #columns>
<el-table-column label="模型名称" prop="name" align="center" />
<el-table-column label="模型名称" prop="name" align="center" min-width="180" />
<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>
<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">
<el-table-column label="用途" align="center" width="110">
<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 label="模型来源" align="center" width="120">
<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>
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
</template>
<template #actions="{ row }">
<el-button type="primary" link size="small" @click="editModel(row)">
<i class="fa fa-edit" style="margin-right: 4px" />编辑
<el-button type="primary" link size="small" @click="editModel(asModelItem(row))">
<i class="fa fa-edit" /> 编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDeleteConfig(row)">
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
<el-button type="danger" link size="small" @click="handleDeleteConfig(asModelItem(row))">
<i class="fa fa-trash-o" /> 删除
</el-button>
</template>
</DataTablePage>
<!-- 训练模型列表 -->
<DataTablePage
v-else
title=""
title="训练模型"
:data="trainedList"
:loading="loading"
searchable
:search-fields="['name']"
row-key="name"
@refresh="loadTrained"
row-key="id"
@expand-change="handleExpandChange"
@refresh="handleRefresh"
>
<template #title>
<div class="capsule-tabs">
@@ -197,62 +265,96 @@ onMounted(loadData)
</div>
</template>
<template #columns>
<el-table-column label="模型名称" prop="name" align="center" />
<el-table-column label="训练方法" align="center">
<el-table-column type="expand" width="48">
<template #default="{ row }">
{{ row.train_methods?.[0]?.name || '-' }}
<div class="runtime-panel" v-loading="runtimeOf(asTrainedModel(row)).loading">
<section>
<div class="panel-title">
<strong>模型产物</strong>
<el-button link type="primary" size="small" @click="loadTrainedRuntime(asTrainedModel(row), true)">刷新</el-button>
</div>
<el-table :data="runtimeOf(asTrainedModel(row)).artifacts" size="small" empty-text="暂无产物记录">
<el-table-column label="类型" width="120">
<template #default="{ row: item }">{{ artifactTypeLabel(item.artifact_type) }}</template>
</el-table-column>
<el-table-column label="路径" prop="path" min-width="280" show-overflow-tooltip />
<el-table-column label="大小" width="110">
<template #default="{ row: item }">{{ formatBytes(item.size_bytes) }}</template>
</el-table-column>
<el-table-column label="Checksum" min-width="220" show-overflow-tooltip>
<template #default="{ row: item }">{{ item.checksum_sha256 || '-' }}</template>
</el-table-column>
</el-table>
</section>
<section>
<div class="panel-title"><strong>模型血缘</strong></div>
<el-table
:data="[...runtimeOf(asTrainedModel(row)).lineage.parents, ...runtimeOf(asTrainedModel(row)).lineage.children]"
size="small"
empty-text="暂无血缘记录"
>
<el-table-column label="关系" width="180">
<template #default="{ row: item }">{{ relationLabel(item.relation_type) }}</template>
</el-table-column>
<el-table-column label="父资源" min-width="220">
<template #default="{ row: item }">
{{ item.parent_resource_type }} / {{ item.parent_resource_id }}
</template>
</el-table-column>
<el-table-column label="子资源" min-width="220">
<template #default="{ row: item }">
{{ item.child_resource_type }} / {{ item.child_resource_id }}
</template>
</el-table-column>
<el-table-column label="Compute Job" prop="compute_job_id" min-width="160" show-overflow-tooltip />
</el-table>
</section>
<section>
<div class="panel-title"><strong>导出任务</strong></div>
<el-table :data="runtimeOf(asTrainedModel(row)).exportJobs" size="small" empty-text="暂无导出任务">
<el-table-column label="任务ID" prop="id" min-width="160" show-overflow-tooltip />
<el-table-column label="类型" prop="export_type" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row: item }">
<el-tag :type="statusTagType(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="输出目录" prop="output_dir" min-width="260" show-overflow-tooltip />
<el-table-column label="创建时间" width="180">
<template #default="{ row: item }">{{ formatTime(item.create_time) }}</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
</el-table-column>
<el-table-column label="模型名称" prop="name" align="center" min-width="180" />
<el-table-column label="训练方法" align="center" width="110">
<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>
<el-tag :type="mergeStatusType(asTrainedModel(row))" size="small">{{ mergeStatusLabel(asTrainedModel(row)) }}</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>
<template #default="{ row }">{{ formatTime(row.create_time) }}</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 v-if="!row.merged && !row.merging" type="primary" link size="small" @click="handleMerge(asTrainedModel(row))">
<i class="fa fa-code-fork" /> 合并权重
</el-button>
<el-button
v-else-if="row.merging"
type="info"
link
size="small"
:loading="true"
disabled
>
合并中
<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(asTrainedModel(row))">
<i class="fa fa-eraser" /> 删除权重
</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>
</div>
</template>
</DataTablePage>
@@ -260,37 +362,33 @@ onMounted(loadData)
</template>
<style scoped lang="scss">
.model-manage-page {
height: 100%;
}
/* 胶囊切换栏样式 */
.capsule-tabs {
display: flex;
background: #f1f5f9;
padding: 3px;
border-radius: 8px;
gap: 2px;
padding: 3px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f1f5f9;
}
.capsule-tab-item {
border: 0;
border-radius: 6px;
background: transparent;
padding: 6px 20px;
color: #64748b;
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);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
font-weight: 600;
}
}
@@ -300,4 +398,19 @@ onMounted(loadData)
justify-content: center;
gap: 8px;
}
.runtime-panel {
display: grid;
gap: 16px;
padding: 16px 24px;
background: #f8fafc;
}
.panel-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
color: #1f2937;
}
</style>

View File

@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { getUsers } from '@/api/modules/system'
import type { SystemUser } from '@/types'
import { statusLabel, statusTagType } from '@/utils/status'
const loading = ref(false)
const users = ref<SystemUser[]>([])
@@ -32,7 +33,11 @@ onMounted(loadUsers)
<el-table-column prop="username" label="账号" min-width="140" />
<el-table-column prop="display_name" label="显示名称" min-width="160" />
<el-table-column prop="role" label="角色" width="120" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="120">
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
</el-table-column>