更新前端看板
This commit is contained in:
@@ -64,6 +64,27 @@ export const streamChat = async (data: any): Promise<any> => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实流式对话 — 使用 fetch 调用后端 SSE 端点,返回 Response 供 ReadableStream 消费 */
|
||||
export const streamChatReal = (data: any): Promise<Response> => {
|
||||
const messages = data.messages || []
|
||||
if (!messages.length && data.user_question) {
|
||||
if (data.system_prompt) {
|
||||
messages.push({ role: 'system', content: data.system_prompt })
|
||||
}
|
||||
messages.push({ role: 'user', content: data.user_question })
|
||||
}
|
||||
return fetch('/modelTF/model-compare/stream-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
temperature: data.temperature ?? 0.7,
|
||||
top_p: data.top_p ?? 0.95,
|
||||
max_tokens: data.max_tokens ?? 2048,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** 非流式对话(按端口代理) */
|
||||
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
|
||||
|
||||
@@ -73,8 +94,8 @@ export const batchChat = (data: any) => post('/model-chat/batch', data)
|
||||
/** 本地 transformers 模型对话 */
|
||||
export const localChat = (data: any) => post('/model-chat/local/chat', data)
|
||||
|
||||
/** 预加载本地模型 */
|
||||
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data)
|
||||
/** 预加载本地模型(模型加载耗时长,超时 5 分钟) */
|
||||
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data, { timeout: 300000 })
|
||||
|
||||
/** 预加载已训练模型 */
|
||||
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data)
|
||||
/** 预加载已训练模型(超时 5 分钟) */
|
||||
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data, { timeout: 300000 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post, put } from '../request'
|
||||
import { del, get, post, put } from '../request'
|
||||
|
||||
export interface ComputeNode {
|
||||
id: string
|
||||
@@ -95,6 +95,9 @@ export const createComputeNode = (data: ComputeNodePayload) =>
|
||||
export const updateComputeNode = (id: string, data: Partial<ComputeNode>) =>
|
||||
put<ComputeNode>(`/compute/nodes/${id}`, data)
|
||||
|
||||
export const deleteComputeNode = (id: string) =>
|
||||
del<{ deleted: string }>(`/compute/nodes/${id}`)
|
||||
|
||||
export const testComputeNode = (id: string) =>
|
||||
post<{ node_id: string; success: boolean; latency_ms: number; gpu_count: number; error?: string }>(`/compute/nodes/${id}/test-connection`)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const uploadDatasetFiles = (datasetId: string | number, files: File[]) =>
|
||||
files.forEach((f) => formData.append('files', f))
|
||||
return post(`/dataset-manage/upload/${datasetId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { streamChat } from '@/api/modules/compare'
|
||||
import { streamChat, streamChatReal } from '@/api/modules/compare'
|
||||
|
||||
export interface StreamMessage {
|
||||
/** 用户问题 */
|
||||
@@ -20,6 +20,11 @@ export interface StreamMessage {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
/** 是否使用 mock 模式(默认 true,向后兼容) */
|
||||
useMock?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话 composable
|
||||
* 移植自原 model-chat.html:
|
||||
@@ -65,8 +70,10 @@ export function useStreamChat() {
|
||||
/**
|
||||
* 发起流式对话
|
||||
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
|
||||
* @param options 可选配置 { useMock?: boolean }
|
||||
*/
|
||||
async function send(payload: any) {
|
||||
async function send(payload: any, options?: SendOptions) {
|
||||
const useMock = options?.useMock ?? true
|
||||
loading.value = true
|
||||
message.value = {
|
||||
question: payload.user_question || '',
|
||||
@@ -82,7 +89,10 @@ export function useStreamChat() {
|
||||
const UPDATE_INTERVAL = 50 // 50ms 节流
|
||||
|
||||
try {
|
||||
const response = await streamChat(payload)
|
||||
const response = useMock
|
||||
? await streamChat(payload)
|
||||
: await streamChatReal(payload)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface FineTuneTask {
|
||||
train_dataset_id?: number | string
|
||||
auto_merge?: boolean
|
||||
output_model_name?: string
|
||||
compute_node_id?: string
|
||||
gpus?: number[]
|
||||
batch_size?: number
|
||||
learning_rate?: number
|
||||
@@ -355,16 +356,16 @@ export interface GpuInfo {
|
||||
power_w: number
|
||||
id?: number
|
||||
uuid?: string
|
||||
status?: 'idle' | 'busy' | 'warning' | 'offline'
|
||||
status?: 'idle' | 'busy' | 'reserved' | 'warning' | 'offline'
|
||||
memory_percent?: number
|
||||
power_limit_w?: number
|
||||
processes?: GpuProcess[]
|
||||
fan_speed?: number
|
||||
clock_mhz?: number
|
||||
driver_version?: string
|
||||
node_id?: string
|
||||
node_code?: string
|
||||
node_name?: string
|
||||
driver_version?: string
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 字段),仍可上传'
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '(无回复)'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('请填写租户名称')
|
||||
|
||||
Reference in New Issue
Block a user