feat: 添加平台管理、计算模块适配器及前端页面更新
- 新增 platform API 端点和存储 - 新增 llama_factory 适配器 - 新增前端 compute、guide、system 等视图页面 - 新增 echarts 插件和 mock 数据 - 更新 Docker 配置、后端配置及文档 - 更新前端路由、API、侧边栏等组件 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
85
frontend/src/api/modules/compute.ts
Normal file
85
frontend/src/api/modules/compute.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { get, post, put } from '../request'
|
||||
|
||||
export interface ComputeNode {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
api_base_url: string
|
||||
file_gateway_url: string
|
||||
enabled: boolean
|
||||
scheduler_status: string
|
||||
scheduler_weight: number
|
||||
tags: string[]
|
||||
gpu_count: number
|
||||
current_running_jobs: number
|
||||
max_parallel_jobs: number
|
||||
data_root: string
|
||||
model_root: string
|
||||
log_root: string
|
||||
last_health_check_at?: string
|
||||
}
|
||||
|
||||
export interface ComputeGpu {
|
||||
id: number
|
||||
node_id: string
|
||||
node_code: string
|
||||
node_name: string
|
||||
name: string
|
||||
uuid: string
|
||||
status: string
|
||||
gpu_percent: number
|
||||
memory_used_gb: number
|
||||
memory_total_gb: number
|
||||
memory_percent: number
|
||||
temperature: number
|
||||
power_w: number
|
||||
power_limit_w: number
|
||||
processes?: Array<{
|
||||
pid: number
|
||||
name: string
|
||||
memory_used_gb: number
|
||||
task_name?: string
|
||||
user?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ComputeQueueItem {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
progress: number
|
||||
compute_node_id?: string
|
||||
gpus: number[]
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface ResourceReplica {
|
||||
id: string
|
||||
node_id: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
local_path: string
|
||||
status: string
|
||||
sync_status: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export const getComputeNodes = () => get<ComputeNode[]>('/compute/nodes')
|
||||
|
||||
export const updateComputeNode = (id: string, data: Partial<ComputeNode>) =>
|
||||
put<ComputeNode>(`/compute/nodes/${id}`, data)
|
||||
|
||||
export const testComputeNode = (id: string) =>
|
||||
post<{ node_id: string; success: boolean; latency_ms: number }>(`/compute/nodes/${id}/test-connection`)
|
||||
|
||||
export const enableComputeNode = (id: string) => post<ComputeNode>(`/compute/nodes/${id}/enable`)
|
||||
|
||||
export const disableComputeNode = (id: string) => post<ComputeNode>(`/compute/nodes/${id}/disable`)
|
||||
|
||||
export const drainComputeNode = (id: string) => post<ComputeNode>(`/compute/nodes/${id}/drain`)
|
||||
|
||||
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`)
|
||||
@@ -12,7 +12,7 @@ export interface ApiResult<T = any> {
|
||||
}
|
||||
|
||||
const service: AxiosInstance = axios.create({
|
||||
// 统一走相对路径,由 Vite 代理转发到 http://localhost:7861
|
||||
// Use a relative path; Vite proxies /api to http://localhost:17861 in local development.
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
BIN
frontend/src/assets/login-hero-flow.jpg
Normal file
BIN
frontend/src/assets/login-hero-flow.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -68,6 +68,12 @@ const menuGroups: MenuGroup[] = [
|
||||
title: '其他工具',
|
||||
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert', permission: 'data-convert' }],
|
||||
},
|
||||
{
|
||||
title: '算力资源',
|
||||
items: [
|
||||
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
|
||||
144
frontend/src/mock/users.ts
Normal file
144
frontend/src/mock/users.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
CreateUserPayload,
|
||||
LoginResponse,
|
||||
PermissionCode,
|
||||
SystemUser,
|
||||
UpdateUserAccessPayload,
|
||||
} from '@/types'
|
||||
|
||||
const STORAGE_KEY = 'mock:system-users'
|
||||
|
||||
const allPermissions: PermissionCode[] = [
|
||||
'dashboard',
|
||||
'fine-tune',
|
||||
'model-eval',
|
||||
'model-inference',
|
||||
'model-manage',
|
||||
'dataset',
|
||||
'data-process',
|
||||
'data-convert',
|
||||
'compute',
|
||||
'hardware',
|
||||
'logs',
|
||||
'user-settings',
|
||||
]
|
||||
|
||||
const defaultPasswords: Record<string, string> = {
|
||||
admin: 'admin123',
|
||||
operator: 'operator123',
|
||||
}
|
||||
|
||||
export class UserMutationError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message)
|
||||
this.name = 'UserMutationError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function defaultUsers(): SystemUser[] {
|
||||
return [
|
||||
{
|
||||
id: 'u_admin',
|
||||
username: 'admin',
|
||||
display_name: 'Platform Admin',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
permissions: allPermissions,
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
id: 'u_operator',
|
||||
username: 'operator',
|
||||
display_name: 'Platform Operator',
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: allPermissions.filter((item) => item !== 'user-settings'),
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function readUsers(): SystemUser[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return defaultUsers()
|
||||
try {
|
||||
const users = JSON.parse(raw) as SystemUser[]
|
||||
return users.length ? users : defaultUsers()
|
||||
} catch {
|
||||
return defaultUsers()
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsers(users: SystemUser[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(users))
|
||||
}
|
||||
|
||||
export function listMockUsers(): SystemUser[] {
|
||||
return readUsers()
|
||||
}
|
||||
|
||||
export function authenticateMockUser(username: string, password: string): LoginResponse {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.username === username)
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UserMutationError('Invalid username or disabled account', 401)
|
||||
}
|
||||
const expected = defaultPasswords[username] || 'platform123'
|
||||
if (password !== expected) {
|
||||
throw new UserMutationError('Invalid username or password', 401)
|
||||
}
|
||||
user.last_login = new Date().toISOString()
|
||||
writeUsers(users)
|
||||
return {
|
||||
token: `mock-token-${user.id}`,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockUser(payload: CreateUserPayload): SystemUser {
|
||||
const users = readUsers()
|
||||
if (users.some((item) => item.username === payload.username)) {
|
||||
throw new UserMutationError('Username already exists', 409)
|
||||
}
|
||||
const user: SystemUser = {
|
||||
id: `u_${Date.now()}`,
|
||||
username: payload.username,
|
||||
display_name: payload.display_name || payload.username,
|
||||
role: payload.role,
|
||||
status: payload.status || 'active',
|
||||
permissions: payload.permissions || [],
|
||||
create_time: new Date().toISOString(),
|
||||
protected: false,
|
||||
}
|
||||
defaultPasswords[user.username] = payload.password || 'platform123'
|
||||
users.push(user)
|
||||
writeUsers(users)
|
||||
return user
|
||||
}
|
||||
|
||||
export function updateMockUserAccess(id: string, payload: UpdateUserAccessPayload): SystemUser {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.id === id)
|
||||
if (!user) throw new UserMutationError('User not found', 404)
|
||||
if (payload.role) user.role = payload.role
|
||||
if (payload.status) user.status = payload.status
|
||||
if (payload.permissions) user.permissions = payload.permissions
|
||||
writeUsers(users)
|
||||
return user
|
||||
}
|
||||
|
||||
export function deleteMockUser(id: string, currentUsername: string): { deleted: string } {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.id === id)
|
||||
if (!user) throw new UserMutationError('User not found', 404)
|
||||
if (user.protected || user.username === currentUsername) {
|
||||
throw new UserMutationError('Protected or current user cannot be deleted', 400)
|
||||
}
|
||||
writeUsers(users.filter((item) => item.id !== id))
|
||||
return { deleted: id }
|
||||
}
|
||||
17
frontend/src/plugins/echarts-hardware.ts
Normal file
17
frontend/src/plugins/echarts-hardware.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { GaugeChart, LineChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
GaugeChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
])
|
||||
18
frontend/src/plugins/echarts-training-log.ts
Normal file
18
frontend/src/plugins/echarts-training-log.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
])
|
||||
@@ -197,6 +197,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/data-convert/DataConvertView.vue'),
|
||||
meta: { title: '数据类型转换' },
|
||||
},
|
||||
{
|
||||
path: 'compute',
|
||||
name: 'compute',
|
||||
component: () => import('@/views/compute/ComputeNodesView.vue'),
|
||||
meta: { title: '算力节点', pageSurface: 'self', permission: 'compute' },
|
||||
},
|
||||
{
|
||||
path: 'compute/gpus',
|
||||
redirect: '/compute?tab=gpus',
|
||||
},
|
||||
{
|
||||
path: 'compute/queue',
|
||||
redirect: '/compute?tab=queue',
|
||||
},
|
||||
{
|
||||
path: 'compute/nodes',
|
||||
redirect: '/compute?tab=nodes',
|
||||
},
|
||||
// 系统设置
|
||||
{
|
||||
path: 'permission-denied',
|
||||
@@ -264,6 +282,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
||||
dataset: 'dataset',
|
||||
'data-process': 'data-process',
|
||||
'data-convert': 'data-convert',
|
||||
compute: 'compute',
|
||||
tools: 'data-convert',
|
||||
hardware: 'hardware',
|
||||
logs: 'logs',
|
||||
|
||||
@@ -15,6 +15,7 @@ const allPermissions: PermissionCode[] = [
|
||||
'dataset',
|
||||
'data-process',
|
||||
'data-convert',
|
||||
'compute',
|
||||
'hardware',
|
||||
'logs',
|
||||
'user-settings',
|
||||
|
||||
@@ -394,6 +394,7 @@ export type PermissionCode =
|
||||
| 'dataset'
|
||||
| 'data-process'
|
||||
| 'data-convert'
|
||||
| 'compute'
|
||||
| 'hardware'
|
||||
| 'logs'
|
||||
| 'user-settings'
|
||||
|
||||
376
frontend/src/views/compute/ComputeNodesView.vue
Normal file
376
frontend/src/views/compute/ComputeNodesView.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
disableComputeNode,
|
||||
drainComputeNode,
|
||||
enableComputeNode,
|
||||
getComputeGpus,
|
||||
getComputeNodes,
|
||||
getComputeQueue,
|
||||
getNodeReplicas,
|
||||
testComputeNode,
|
||||
type ComputeGpu,
|
||||
type ComputeNode,
|
||||
type ComputeQueueItem,
|
||||
type ResourceReplica,
|
||||
} from '@/api/modules/compute'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref(String(route.query.tab || 'nodes'))
|
||||
const loading = ref(false)
|
||||
const nodes = ref<ComputeNode[]>([])
|
||||
const gpus = ref<ComputeGpu[]>([])
|
||||
const queue = ref<ComputeQueueItem[]>([])
|
||||
const replicas = ref<ResourceReplica[]>([])
|
||||
const selectedNodeId = ref('')
|
||||
const lastUpdated = ref('')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const selectedNode = computed(() => nodes.value.find((item) => item.id === selectedNodeId.value))
|
||||
const enabledNodes = computed(() => nodes.value.filter((item) => item.enabled).length)
|
||||
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))
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
activeTab.value = String(tab || 'nodes')
|
||||
},
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [nodeList, gpuList, queueList] = await Promise.all([
|
||||
getComputeNodes(),
|
||||
getComputeGpus(),
|
||||
getComputeQueue(),
|
||||
])
|
||||
nodes.value = nodeList
|
||||
gpus.value = gpuList
|
||||
queue.value = queueList
|
||||
if (!selectedNodeId.value && nodeList.length) selectedNodeId.value = nodeList[0].id
|
||||
await loadReplicas()
|
||||
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReplicas() {
|
||||
if (!selectedNodeId.value) {
|
||||
replicas.value = []
|
||||
return
|
||||
}
|
||||
replicas.value = await getNodeReplicas(selectedNodeId.value)
|
||||
}
|
||||
|
||||
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) {
|
||||
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') await testComputeNode(nodeId)
|
||||
await load()
|
||||
}
|
||||
|
||||
function nodeStatusType(status: string) {
|
||||
if (status === 'online') return 'success'
|
||||
if (status === 'draining' || status === 'maintenance') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="compute-page" v-loading="loading">
|
||||
<header class="compute-header">
|
||||
<div>
|
||||
<h1>算力节点</h1>
|
||||
<p>维护训练算力节点、GPU 状态、任务队列和模型/数据集本地副本。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span v-if="lastUpdated" class="last-updated">更新 {{ lastUpdated }}</span>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<el-tabs v-model="activeTab" class="compute-tabs" @tab-change="changeTab">
|
||||
<el-tab-pane label="节点" name="nodes">
|
||||
<el-table :data="nodes" height="100%">
|
||||
<el-table-column prop="code" label="节点编码" min-width="150" />
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scheduler_weight" label="权重" width="90" />
|
||||
<el-table-column label="标签" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-space wrap>
|
||||
<el-tag v-for="tag in row.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||
</el-space>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务/GPU" width="120">
|
||||
<template #default="{ row }">{{ row.current_running_jobs }} / {{ row.gpu_count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="服务地址" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="mono">{{ row.api_base_url }}</div>
|
||||
<div class="muted mono">{{ row.file_gateway_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="GPU" name="gpus">
|
||||
<el-table :data="gpus" height="100%">
|
||||
<el-table-column label="GPU" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.node_code }} / GPU {{ row.id }}</strong>
|
||||
<div class="muted">{{ row.name }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="gpuStatusType(row.status)">{{ 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>
|
||||
</el-table-column>
|
||||
<el-table-column label="显存" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.memory_percent)" :stroke-width="8" />
|
||||
<span class="muted">{{ row.memory_used_gb }} / {{ row.memory_total_gb }} GB</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="温度/功耗" width="150">
|
||||
<template #default="{ row }">{{ row.temperature }} C / {{ row.power_w }} W</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进程" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.processes?.length" class="muted">空闲</span>
|
||||
<div v-for="process in row.processes" :key="process.pid">
|
||||
{{ process.task_name }} <span class="muted">PID {{ process.pid }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="队列" name="queue">
|
||||
<el-table :data="queue" height="100%">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" min-width="220">
|
||||
<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>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="资源副本" name="replicas">
|
||||
<div class="replica-toolbar">
|
||||
<el-select v-model="selectedNodeId" filterable @change="loadReplicas">
|
||||
<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>
|
||||
</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>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compute-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compute-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.last-updated,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.compute-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
height: calc(100% - 56px);
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.replica-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.el-select {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.compute-header,
|
||||
.header-actions,
|
||||
.replica-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/views/eval/create/BasicMetricSetupStep.vue
Normal file
78
frontend/src/views/eval/create/BasicMetricSetupStep.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
export interface BasicMetricSetupDraft {
|
||||
bleu_enabled: boolean
|
||||
bleu_n: number
|
||||
rouge_enabled: boolean
|
||||
rouge_methods: string[]
|
||||
cosine_enabled: boolean
|
||||
output_precision: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const form = defineModel<BasicMetricSetupDraft>({ required: true })
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules: FormRules<BasicMetricSetupDraft> = {
|
||||
bleu_n: [{ required: true, type: 'number', min: 1, max: 8, message: 'BLEU ngram must be between 1 and 8', trigger: 'change' }],
|
||||
output_precision: [{ required: true, type: 'number', min: 0, max: 8, message: 'Precision must be between 0 and 8', trigger: 'change' }],
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
if (!formRef.value) return false
|
||||
try {
|
||||
return !!(await formRef.value.validate())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
class="basic-metric-form"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<el-form-item label="BLEU">
|
||||
<el-switch v-model="form.bleu_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.bleu_enabled" label="BLEU ngram" prop="bleu_n">
|
||||
<el-input-number v-model="form.bleu_n" :min="1" :max="8" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="ROUGE">
|
||||
<el-switch v-model="form.rouge_enabled" />
|
||||
</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-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Cosine">
|
||||
<el-switch v-model="form.cosine_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Output precision" prop="output_precision">
|
||||
<el-input-number v-model="form.output_precision" :min="0" :max="8" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.basic-metric-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
</style>
|
||||
49
frontend/src/views/eval/create/StartEvalStep.vue
Normal file
49
frontend/src/views/eval/create/StartEvalStep.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DatasetItem, GpuInfo, ModelItem, TrainedModel } from '@/types'
|
||||
import type { BasicMetricSetupDraft } from './BasicMetricSetupStep.vue'
|
||||
import type { EvalRuleSetupDraft } from './EvalRuleSetupStep.vue'
|
||||
import type { EvalTaskSetupDraft } from './EvalTaskSetupStep.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
task: EvalTaskSetupDraft
|
||||
llmMetric: EvalRuleSetupDraft
|
||||
basicMetrics: BasicMetricSetupDraft
|
||||
trainedModels: TrainedModel[]
|
||||
evalDatasets: DatasetItem[]
|
||||
evalModels: ModelItem[]
|
||||
gpus: GpuInfo[]
|
||||
}>()
|
||||
|
||||
function nameOf<T extends { id: string | number; name?: string }>(items: T[], id: string | number) {
|
||||
return items.find((item) => item.id === id)?.name || String(id || '-')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="start-eval-step">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="Task">{{ props.task.eval_task_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Model">{{ nameOf(props.trainedModels, props.task.model_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="GPU">GPU {{ props.task.gpu_id || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Dataset">
|
||||
{{ props.task.data_source === 'dataset' ? nameOf(props.evalDatasets, props.task.dataset_id) : 'Inference results' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Eval model">{{ nameOf(props.evalModels, props.llmMetric.eval_model || '') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Eval method">{{ props.llmMetric.eval_method || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Basic metrics" :span="2">
|
||||
<el-space wrap>
|
||||
<el-tag v-if="props.basicMetrics.bleu_enabled">BLEU-{{ props.basicMetrics.bleu_n }}</el-tag>
|
||||
<el-tag v-if="props.basicMetrics.rouge_enabled">{{ props.basicMetrics.rouge_methods.join(', ') }}</el-tag>
|
||||
<el-tag v-if="props.basicMetrics.cosine_enabled">Cosine</el-tag>
|
||||
<el-tag effect="plain">Precision {{ props.basicMetrics.output_precision }}</el-tag>
|
||||
</el-space>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.start-eval-step {
|
||||
max-width: 920px;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/views/guide/GuideView.vue
Normal file
12
frontend/src/views/guide/GuideView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>使用文档</h1>
|
||||
<p>第一版系统已接入后端、数据集、模型、微调任务、算力节点和训练日志主链路。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/views/system/PermissionDeniedView.vue
Normal file
12
frontend/src/views/system/PermissionDeniedView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>无权访问</h1>
|
||||
<p>当前账号没有访问该页面的权限,请联系管理员调整角色或页面权限。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
94
frontend/src/views/system/UserCreateView.vue
Normal file
94
frontend/src/views/system/UserCreateView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createUser } from '@/api/modules/system'
|
||||
import type { CreateUserPayload, PermissionCode } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive<CreateUserPayload>({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: 'platform123',
|
||||
role: 'viewer',
|
||||
status: 'active',
|
||||
permissions: ['dashboard'],
|
||||
})
|
||||
|
||||
const permissionOptions: PermissionCode[] = [
|
||||
'dashboard',
|
||||
'fine-tune',
|
||||
'model-eval',
|
||||
'model-inference',
|
||||
'model-manage',
|
||||
'dataset',
|
||||
'data-process',
|
||||
'data-convert',
|
||||
'compute',
|
||||
'hardware',
|
||||
'logs',
|
||||
'user-settings',
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createUser(form)
|
||||
ElMessage.success('用户已创建')
|
||||
router.push('/user-settings')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="user-create">
|
||||
<h1>创建用户</h1>
|
||||
<el-form :model="form" label-width="110px" class="user-form">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="form.display_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="观察员" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="active">启用</el-radio>
|
||||
<el-radio value="disabled">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="页面权限">
|
||||
<el-checkbox-group v-model="form.permissions">
|
||||
<el-checkbox v-for="item in permissionOptions" :key="item" :value="item">{{ item }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="router.back()">返回</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-create {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/views/system/UserPermissionView.vue
Normal file
12
frontend/src/views/system/UserPermissionView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>权限设置</h1>
|
||||
<p>第一版已支持账号页面权限读取与保存,精细化项目/模型/数据集权限将在后续版本补齐。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
65
frontend/src/views/system/UserSettingsView.vue
Normal file
65
frontend/src/views/system/UserSettingsView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getUsers } from '@/api/modules/system'
|
||||
import type { SystemUser } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref<SystemUser[]>([])
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="user-settings" v-loading="loading">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>用户设置</h1>
|
||||
<p>管理平台账号、角色状态和页面权限。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</header>
|
||||
|
||||
<el-table :data="users">
|
||||
<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 }">{{ row.permissions?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-settings {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user