feat: 更新 request.ts

This commit is contained in:
wangjiming
2026-08-04 17:18:50 +08:00
31 changed files with 1860 additions and 333 deletions

View File

@@ -1,6 +1,8 @@
import { get, post, del } from '../request'
import type { CompareTask, CompareModelRef } from '@/types'
const INFERENCE_START_TIMEOUT_MS = 15 * 60 * 1000
/** 推理/对比任务列表 */
export const getCompareList = () => get<CompareTask[]>('/model-compare')
@@ -12,7 +14,7 @@ export const createCompare = (data: Partial<CompareTask>) =>
post<{ id: string | number }>('/model-compare', data)
/** 删除任务 */
export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`)
export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`, undefined, { timeout: 60_000 })
/** 更新任务加载状态 */
export const updateLoadStatus = (id: string | number, load_status: any) =>
@@ -34,7 +36,8 @@ export const stopModelByPid = (pid: number) =>
post('/model-compare/stop-by-pid', { pid })
/** 加载任务 */
export const loadCompare = (id: string | number) => post(`/model-compare/${id}/load`)
export const loadCompare = (id: string | number) =>
post(`/model-compare/${id}/load`, undefined, { timeout: INFERENCE_START_TIMEOUT_MS })
/** 卸载任务 */
export const unloadCompare = (id: string | number) => post(`/model-compare/${id}/unload`)
@@ -81,6 +84,10 @@ export const streamChatReal = (data: any): Promise<Response> => {
temperature: data.temperature ?? 0.7,
top_p: data.top_p ?? 0.95,
max_tokens: data.max_tokens ?? 2048,
// 透传 task_id/node_id让后端按 load_status 路由到真正加载了模型的算力节点,
// 避免在多节点时回退到“第一个在线节点”导致连接失败
task_id: data.task_id,
node_id: data.node_id,
}),
})
}
@@ -94,8 +101,8 @@ export const batchChat = (data: any) => post('/model-chat/batch', data)
/** 本地 transformers 模型对话 */
export const localChat = (data: any) => post('/model-chat/local/chat', data)
/** 预加载本地模型(模型加载耗时长,超时 5 分钟) */
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data, { timeout: 300000 })
/** 预加载本地模型(模型加载耗时长,超时 15 分钟) */
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data, { timeout: INFERENCE_START_TIMEOUT_MS })
/** 预加载已训练模型(超时 5 分钟) */
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data, { timeout: 300000 })
/** 预加载已训练模型(超时 15 分钟) */
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data, { timeout: INFERENCE_START_TIMEOUT_MS })

View File

@@ -1,6 +1,16 @@
import { get, post, put, del } from '../request'
import type { FineTuneStartPayload, FineTuneTask, TrainingProgress, LogContent } from '@/types'
export interface FineTuneMetricPoint {
step: number
epoch?: number | null
loss?: number | null
grad_norm?: number | null
learning_rate?: number | null
raw?: string
create_time?: string
}
export interface TrainingDiagnostic {
level: string
title: string
@@ -80,6 +90,10 @@ export const getFineTuneLogs = (
params: { tail_lines?: number; offset?: number; limit?: number } = {},
) => get<LogContent & { job_id?: string; source?: string }>(`/fine-tune/${id}/logs`, params)
/** 获取训练指标曲线数据 */
export const getFineTuneMetrics = (id: string | number) =>
get<FineTuneMetricPoint[]>(`/fine-tune/${id}/metrics`)
/** 启动 TensorBoard */
export const startTensorboard = () => post('/fine-tune/tensorboard/start')

View File

@@ -88,10 +88,14 @@ export const updateModelPurpose = (id: string | number, purpose: string) =>
/** 合并 LoRA 权重 */
export const mergeModel = (data: {
trained_model_id?: string | number
model_name: string
train_method: string
base_model_path: string
}) => post('/model-manage/merge', data)
adapter_path?: string
compute_node_id?: string
output_model_name?: string
}) => post('/model-manage/merge', data, { timeout: 15 * 60 * 1000 })
/** 导出已训练模型权重 */
export const exportModelUrl = (modelName: string) =>

View File

@@ -8,6 +8,8 @@ import type {
UpdateUserAccessPayload,
} from '@/types'
export type { SystemUser } from '@/types'
/** 系统信息CPU/内存/磁盘/GPU/网络/系统) */
export const getSystemInfo = () => get<SystemInfo>('/system-info')

View File

@@ -92,8 +92,6 @@ service.interceptors.response.use(
return response
}
if (res.code === 0) {
// 记录业务模块访问(用于看板用户操作分布统计)
trackVisit(response.config.url)
return res.data
}
// 业务错误

View File

@@ -44,6 +44,24 @@ export function useStreamChat() {
})
const loading = ref(false)
/** 从 SSE 帧中提取错误信息(后端/计算节点错误以 data: {"error": "..."} 形式下发) */
function extractSseError(buffer: string): string | null {
const trimmed = buffer.trim()
if (!trimmed.startsWith('data: ')) return null
const lines = trimmed.split(/\r?\n/)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (!line.startsWith('data: ')) continue
try {
const obj = JSON.parse(line.slice(6))
if (obj && typeof obj.error === 'string' && obj.error) return obj.error
} catch {
/* 非 JSON 的 data 行忽略 */
}
}
return trimmed
}
/** 从内容中解析 think 标签 */
function parseContent(content: string) {
const thinkRegex = /<think>([\s\S]*?)(<\/think>)?/g
@@ -121,6 +139,16 @@ export function useStreamChat() {
}
// 最终更新
// 若整段响应是 SSE 错误帧,提取 error 字段以干净文案展示
const sseError = extractSseError(buffer)
if (sseError) {
message.value.isThinking = false
message.value.isStreaming = false
message.value.done = true
message.value.error = sseError
message.value.displayContent = sseError
return
}
const parsed = parseContent(buffer)
message.value.thinkContent = parsed.think
message.value.displayContent = parsed.display

View File

@@ -390,6 +390,31 @@ router.beforeEach((to, _from, next) => {
}
}
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
const ROUTE_TO_MODULE: Record<string, string> = {
'/fine-tune': 'fine-tune',
'/model-eval': 'model-eval',
'/model-inference': 'model-inference',
'/model-compare': 'model-inference',
'/data-process': 'data-process',
'/data-convert': 'data-convert',
'/model-manage': 'model-manage',
'/dataset-manage': 'dataset',
}
for (const [prefix, module] of Object.entries(ROUTE_TO_MODULE)) {
if (to.path.startsWith(prefix)) {
const key = `route-visit:${module}`
const last = Number(sessionStorage.getItem(key) || 0)
if (Date.now() - last >= 60000) {
sessionStorage.setItem(key, String(Date.now()))
import('@/api/modules/audit-visit').then(({ recordModuleVisit }) => {
recordModuleVisit(module, to.fullPath).catch(() => {})
}).catch(() => {})
}
break
}
}
next()
})

View File

@@ -34,6 +34,10 @@ export interface TrainedModel {
name: string
train_methods?: TrainMethod[]
base_model_path?: string
artifact_dir?: string
adapter_path?: string
compute_node_id?: string
compute_node_name?: string
create_time?: string
merged?: boolean
merging?: boolean
@@ -212,6 +216,9 @@ export interface LoadedModel {
status?: string
pid?: number
port?: number
node_id?: string
node_name?: string
error?: string
}
export interface CompareTask {
@@ -230,6 +237,8 @@ export interface CompareModelRef {
model_name: string
model_path: string
gpu_id: number
node_id?: string
node_name?: string
source?: string
port?: number
}
@@ -245,7 +254,9 @@ export interface EvalTask {
model_name?: string
model_id?: number | string
dataset?: string
dataset_id?: number | string
metric?: string
metric_label?: string
score?: number
status?: string
create_time?: string

View File

@@ -66,8 +66,12 @@ const operationDistribution = ref<{ name: string; value: number }[]>([])
const serviceIcon: Record<string, string> = {
'模型推理': 'fa-cube',
'模型微调': 'fa-sliders',
'模型训练': 'fa-sliders',
'模型评测': 'fa-bar-chart',
'模型管理': 'fa-cubes',
'数据集管理': 'fa-file-text',
'数据处理': 'fa-filter',
'数据类型转换': 'fa-exchange',
}
const roleLabel: Record<string, string> = {
admin: '超级管理员',
@@ -115,7 +119,7 @@ const chartOption = computed<EChartsOption>(() => ({
borderWidth: 0,
padding: [10, 12],
textStyle: { color: '#ffffff', fontSize: 12 },
valueFormatter: (value) => `${value}`,
valueFormatter: (value) => String(value ?? ''),
},
xAxis: {
type: 'category',
@@ -252,7 +256,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (value: number) => `${value} 小时`,
valueFormatter: (value: unknown) => String(Number(Array.isArray(value) ? value[0] : value) || 0) + ' 小时',
},
xAxis: {
type: 'value',
@@ -738,10 +742,11 @@ function viewTask(task: DashboardTask) {
.service-table {
display: grid;
grid-template-rows: 36px repeat(4, minmax(48px, 1fr));
grid-auto-rows: minmax(44px, auto);
flex: 1 1 auto;
margin-top: 12px;
min-height: 0;
overflow-y: auto;
}
.service-row {
@@ -935,7 +940,7 @@ function viewTask(task: DashboardTask) {
}
.service-table {
grid-template-rows: 32px repeat(4, minmax(40px, 1fr));
grid-auto-rows: minmax(38px, auto);
margin-top: 8px;
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@@ -10,8 +10,7 @@ import StartEvalStep from './create/StartEvalStep.vue'
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 { getComputeGpus } from '@/api/modules/compute'
import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
type StepExposed = { validate: () => Promise<boolean> }
@@ -83,9 +82,8 @@ async function loadData() {
const results = await Promise.allSettled([
getTrainedModels(),
getDatasetList(),
getSystemInfo(),
getModelList(),
getComputeNodes(),
getComputeGpus(),
])
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
@@ -93,18 +91,12 @@ async function loadData() {
evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
}
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)),
evalModels.value = (results[2].value || []).filter(
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
)
}
if (results[3].status === 'fulfilled') {
evalModels.value = (results[3].value || []).filter(
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
)
gpus.value = ((results[3].value || []) as unknown as GpuInfo[]).filter((g) => g.status === 'idle')
}
const failedCount = results.filter((result) => result.status === 'rejected').length

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
@@ -50,6 +50,8 @@ const passRate = computed(() => {
})
const overallScore = computed(() => formatScore(detail.value?.overall_score, detail.value?.overall_score_max))
const displayModelName = computed(() => detail.value?.model_name || String(detail.value?.model_id || '-'))
const displayMetric = computed(() => detail.value?.metric_label || detail.value?.metric || '-')
function formatDateTime(value?: string) {
if (!value) return '-'
@@ -121,9 +123,9 @@ onUnmounted(stopPolling)
</div>
<dl class="task-meta">
<div><dt>任务 ID</dt><dd>{{ detail?.id || taskId }}</dd></div>
<div><dt>评测模型</dt><dd>{{ detail?.model_name || '-' }}</dd></div>
<div><dt>评测模型</dt><dd>{{ displayModelName }}</dd></div>
<div><dt>测试集</dt><dd>{{ detail?.dataset || '-' }}</dd></div>
<div><dt>评测指标</dt><dd>{{ detail?.metric || '-' }}</dd></div>
<div><dt>评测指标</dt><dd>{{ displayMetric }}</dd></div>
</dl>
</div>
</div>
@@ -141,7 +143,7 @@ onUnmounted(stopPolling)
<div class="overview-item score-hero">
<span>综合得分</span>
<strong>{{ overallScore }}</strong>
<small>模型综合评分</small>
<small>模型综合评分</small>
</div>
<div class="overview-item">
<span>样本通过率</span>
@@ -164,7 +166,7 @@ onUnmounted(stopPolling)
<div class="review-copy">
<div class="section-heading">
<div>
<h2 id="overall-review-title">大模型综合评价</h2>
<h2 id="overall-review-title">综合评价</h2>
<p>基于全部已评测样本生成的总体结论</p>
</div>
<el-tag v-if="detail.evaluator_model" type="primary" size="small">
@@ -172,7 +174,7 @@ onUnmounted(stopPolling)
</el-tag>
</div>
<p class="review-text">
{{ detail.overall_evaluation || (detail.status === 'running' ? '评测在进行,综合评价将在样本评分完成后生成。' : '暂无综合评价。') }}
{{ detail.overall_evaluation || (detail.status === 'running' ? '评测在进行,综合评价将在样本完成后生成。' : '暂无综合评价。') }}
</p>
<div class="suggestion-block">
@@ -190,8 +192,8 @@ onUnmounted(stopPolling)
<section v-if="detail.dimension_summary?.length" class="dimension-summary" aria-labelledby="dimension-title">
<div class="section-heading compact-heading">
<div>
<h2 id="dimension-title">维度表现</h2>
<p>查看各评测维度的得分与样本通过率</p>
<h2 id="dimension-title">指标表现</h2>
<p>查看各评测指标的得分与通过率</p>
</div>
</div>
<div class="dimension-grid">
@@ -212,22 +214,10 @@ onUnmounted(stopPolling)
<p> {{ filteredSamples.length }} 条结果展开行可查看评分依据与子维度分数</p>
</div>
<div class="sample-filters" aria-label="样本筛选">
<el-input
v-model="keyword"
clearable
placeholder="搜索问题、回答或评价"
aria-label="搜索样本"
@input="resetPage"
>
<el-input v-model="keyword" clearable placeholder="搜索问题、回答或评价" aria-label="搜索样本" @input="resetPage">
<template #prefix><i class="fa fa-search" aria-hidden="true" /></template>
</el-input>
<el-select
v-model="judgementFilter"
clearable
placeholder="全部判定"
aria-label="按判定筛选"
@change="resetPage"
>
<el-select v-model="judgementFilter" clearable placeholder="全部判定" aria-label="按判定筛选" @change="resetPage">
<el-option label="正确" value="正确" />
<el-option label="部分正确" value="部分正确" />
<el-option label="错误" value="错误" />
@@ -235,18 +225,12 @@ onUnmounted(stopPolling)
</div>
</div>
<el-table
v-if="filteredSamples.length"
class="sample-results-table"
:data="paginatedSamples"
row-key="id"
table-layout="fixed"
>
<el-table v-if="filteredSamples.length" class="sample-results-table" :data="paginatedSamples" row-key="id" table-layout="fixed">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="sample-detail-grid">
<div class="evaluation-reason">
<span>大模型评分依据</span>
<span>评分依据</span>
<p>{{ row.evaluation_reason || '暂无评分依据。' }}</p>
</div>
<div v-if="row.error_type" class="error-type">
@@ -275,9 +259,7 @@ onUnmounted(stopPolling)
<template #default="{ row }"><p class="cell-copy">{{ row.model_output || '等待生成' }}</p></template>
</el-table-column>
<el-table-column label="得分" width="90" align="center">
<template #default="{ row }">
<span class="sample-score">{{ formatScore(row.score, row.max_score) }}</span>
</template>
<template #default="{ row }"><span class="sample-score">{{ formatScore(row.score, row.max_score) }}</span></template>
</el-table-column>
<el-table-column label="判定" width="96" align="center">
<template #default="{ row }">
@@ -294,21 +276,11 @@ onUnmounted(stopPolling)
<p>{{ detail.status === 'running' ? '任务正在运行,结果生成后会显示在这里。' : '请调整筛选条件或稍后重试。' }}</p>
</div>
<el-pagination
v-if="filteredSamples.length > pageSize"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
background
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
:total="filteredSamples.length"
aria-label="样本结果分页"
/>
<el-pagination v-if="filteredSamples.length > pageSize" v-model:current-page="currentPage" v-model:page-size="pageSize" background layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="filteredSamples.length" aria-label="样本结果分页" />
</section>
</template>
</PageCard>
</template>
<style scoped lang="scss">
.eval-detail-page {
min-width: 0;
@@ -767,3 +739,6 @@ onUnmounted(stopPolling)
}
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -57,6 +57,14 @@ function handleViewDetail(row: any) {
router.push({ name: 'model-eval-detail', params: { id: row.id } })
}
function displayModelName(row: Partial<EvalTask>) {
return row.model_name || String(row.model_id || '-')
}
function displayMetric(row: Partial<EvalTask>) {
return row.metric_label || row.metric || '-'
}
const { start: startPolling, stop: stopPolling } = usePolling(
async () => {
await loadEvalList({ silent: true })
@@ -102,9 +110,21 @@ onUnmounted(() => {
</template>
<template #columns>
<el-table-column label="任务名称" prop="eval_task_name" align="center" />
<el-table-column label="评测模型" prop="model_name" align="center" />
<el-table-column label="评测模型" align="center" min-width="160">
<template #default="{ row }">
<el-tooltip :content="displayModelName(row)" placement="top" :disabled="displayModelName(row).length < 18">
<span class="cell-ellipsis">{{ displayModelName(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="数据集" prop="dataset" align="center" />
<el-table-column label="指标" prop="metric" align="center" />
<el-table-column label="指标" align="center" min-width="220">
<template #default="{ row }">
<el-tooltip :content="displayMetric(row)" placement="top" :disabled="displayMetric(row).length < 24">
<span class="cell-ellipsis">{{ displayMetric(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="评分" prop="score" width="100" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
@@ -159,7 +179,7 @@ onUnmounted(() => {
min-height: 0;
}
/* 胶囊切换栏样式 */
/* 胶囊切换栏 */
.capsule-tabs {
display: flex;
background: #f1f5f9;
@@ -193,4 +213,13 @@ onUnmounted(() => {
}
}
.cell-ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
</style>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, watch } from 'vue'
import { ref, reactive, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MarkdownView from '@/components/MarkdownView.vue'
import { useStreamChat } from '@/composables/useStreamChat'
import { getCompare } from '@/api/modules/compare'
import { getCompare, getLoadStatus } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
const route = useRoute()
@@ -37,6 +37,10 @@ const contentRef = ref<HTMLElement>()
let activeAssistant: ChatMessage | null = null
/** 设置面板抽屉 */
const showSettings = ref(false)
/** 模型仍在加载中(直接 URL 进入 chat 时兜底轮询就绪状态) */
const taskLoading = ref(false)
const taskError = ref('')
let statusTimer: ReturnType<typeof setInterval> | null = null
/** 获取任务信息定位已启动的模型mock 模式跳过) */
async function loadTask() {
@@ -45,6 +49,14 @@ async function loadTask() {
task.value = await getCompare(taskId)
const models = parseLoadedModels(task.value)
if (models[0]?.model_name) modelName.value = models[0].model_name
// 恢复本地保存的历史对话
restoreHistory()
// 模型仍在上次加载中:启动轮询等待就绪
if (models.some((m) => m.status === 'starting')) {
taskLoading.value = true
await pollTaskStatus()
statusTimer = setInterval(pollTaskStatus, 3000)
}
} catch {
// ignore
}
@@ -60,6 +72,80 @@ function parseLoadedModels(t: CompareTask | null): LoadedModel[] {
}
}
/** 对话历史本地持久化(按任务 id 存储,退出重进可恢复) */
const STORAGE_PREFIX = 'ygft_chat_history_'
function historyKey(id: string | number): string {
return `${STORAGE_PREFIX}${id}`
}
function saveHistory() {
if (isMock) return
try {
const snapshot = messages.value.map((m) => ({
role: m.role,
content: m.content,
think: m.think,
done: true,
}))
localStorage.setItem(historyKey(taskId), JSON.stringify(snapshot))
} catch {
// 存储失败忽略
}
}
function restoreHistory() {
if (isMock) return
try {
const raw = localStorage.getItem(historyKey(taskId))
if (!raw) return
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
messages.value = parsed.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content || '',
think: m.think || '',
isThinking: false,
isStreaming: false,
done: true,
}))
}
} catch {
// 恢复失败忽略
}
}
/** 停止就绪状态轮询 */
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
/** 轮询任务加载状态starting → ready/error */
async function pollTaskStatus() {
try {
const st = await getLoadStatus(taskId)
const items = st.loaded_models || []
const anyReady = items.some((m) => m.status === 'ready' || m.status === 'running')
const anyError = items.some((m) => m.status === 'error')
if (anyReady) {
taskLoading.value = false
taskError.value = ''
stopStatusPolling()
} else if (anyError) {
taskLoading.value = false
taskError.value = items.find((m) => m.status === 'error')?.error || '模型加载失败'
stopStatusPolling()
} else {
taskLoading.value = true
}
} catch {
// 轮询失败忽略,下次再试
}
}
async function handleSend() {
const question = inputQuestion.value.trim()
if (!question || loading.value) return
@@ -76,6 +162,7 @@ async function handleSend() {
done: false,
})
messages.value.push(assistantMsg)
saveHistory()
inputQuestion.value = ''
await nextTick()
@@ -85,6 +172,7 @@ async function handleSend() {
// mock 模式:直接用假数据逐字填充
if (isMock) {
await mockReply(assistantMsg, question)
saveHistory()
return
}
@@ -94,6 +182,7 @@ async function handleSend() {
await send(
{
model_path: route.query.model_path as string || '',
task_id: taskId,
system_prompt: systemPrompt.value,
user_question: question,
temperature: temperature.value,
@@ -111,6 +200,7 @@ async function handleSend() {
assistantMsg.done = true
activeAssistant = null
reset()
saveHistory()
await nextTick()
scrollToBottom()
}
@@ -169,6 +259,11 @@ function handleNewChat() {
activeAssistant = null
messages.value = []
reset()
try {
localStorage.removeItem(historyKey(taskId))
} catch {
// 忽略
}
}
/** 输入框自适应高度 */
@@ -185,6 +280,7 @@ function resetInputHeight() {
}
onMounted(loadTask)
onUnmounted(stopStatusPolling)
</script>
<template>
@@ -252,6 +348,12 @@ onMounted(loadTask)
<!-- 输入栏 -->
<footer class="chat-input-container">
<div v-if="taskLoading" class="loading-hint">
<i class="fa fa-spinner fa-spin" style="margin-right: 6px" />模型加载中就绪后即可对话...
</div>
<div v-else-if="taskError" class="loading-hint error">
<i class="fa fa-exclamation-circle" style="margin-right: 6px" />{{ taskError }}
</div>
<div class="chat-input-inner">
<button class="clear-btn" title="清空对话" @click="handleNewChat">
<i class="fa fa-eraser" />
@@ -261,22 +363,21 @@ onMounted(loadTask)
v-model="inputQuestion"
class="input-box"
rows="1"
:disabled="loading"
:disabled="loading || taskLoading"
placeholder="给模型发送消息..."
@keydown.enter.exact.prevent="handleSend"
@input="autoResize"
/>
<button
class="send-btn"
:class="{ active: inputQuestion.trim() && !loading }"
:disabled="!inputQuestion.trim() || loading"
:class="{ active: inputQuestion.trim() && !loading && !taskLoading }"
:disabled="!inputQuestion.trim() || loading || taskLoading"
@click="handleSend"
>
<i class="fa fa-arrow-up" />
</button>
</div>
</div>
<div class="footer-hint">内容由 AI 生成请仔细甄别</div>
</footer>
<!-- 设置抽屉系统提示词等 -->
@@ -703,9 +804,18 @@ onMounted(loadTask)
}
}
.footer-hint {
margin-top: 12px;
font-size: 12px;
color: #9ca3af;
.loading-hint {
margin-bottom: 10px;
padding: 6px 14px;
font-size: 13px;
color: #b45309;
background: #fef3c7;
border-radius: 8px;
text-align: center;
&.error {
color: #b91c1c;
background: #fee2e2;
}
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
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 { createCompare, loadCompare } from '@/api/modules/compare'
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
const router = useRouter()
@@ -27,6 +27,8 @@ interface SelectableModel {
name: string
source: 'database' | 'trained'
model_path: string
compute_node_id?: string
compute_node_name?: string
merged?: boolean
merging?: boolean
disabled?: boolean
@@ -51,6 +53,8 @@ const trainedOptions = computed<SelectableModel[]>(() =>
name: m.name,
source: 'trained',
model_path: m.merged_path || m.base_model_path || '',
compute_node_id: m.compute_node_id,
compute_node_name: m.compute_node_name,
merged: m.merged,
merging: m.merging,
disabled: m.merged === false,
@@ -80,7 +84,7 @@ const form = reactive({
/** 选中的模型 key单选 */
model_key: '',
/** 使用的 GPU */
gpu_id: 0,
gpu_key: '',
})
const rules: FormRules = {
@@ -90,6 +94,13 @@ const rules: FormRules = {
/** 当前选中的模型对象 */
const selectedModel = computed(() => modelMap.value[form.model_key])
const selectedGpu = computed(() => idleGpus.value.find((g) => `${g.node_id || ''}:${g.id ?? 0}` === form.gpu_key))
watch(selectedModel, (model) => {
if (!model?.compute_node_id) return
const gpu = idleGpus.value.find((item) => item.node_id === model.compute_node_id)
if (gpu) form.gpu_key = `${gpu.node_id || ''}:${gpu.id ?? 0}`
})
async function handleSubmit() {
if (!formRef.value) return
@@ -101,62 +112,47 @@ async function handleSubmit() {
return
}
submitting.value = true
startupStatus.value = '正在启动模型服务...'
startupStatus.value = '正在创建推理任务...'
try {
// 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 = ''
if (!m.model_path) {
ElMessage.warning('当前模型未配置算力节点可访问路径,请先在模型管理中维护模型路径')
return
}
// Step 2: 创建推理任务记录
// Step 1: 创建推理任务记录
const taskResult = await createCompare({
name: form.name || m.name,
description: form.description,
status: 'pending',
models: [
{
model_id: String(m.id),
model_name: m.name,
model_path: m.model_path,
source: m.source,
gpu_id: form.gpu_id,
gpu_id: selectedGpu.value?.id ?? 0,
node_id: selectedGpu.value?.node_id || m.compute_node_id,
node_name: selectedGpu.value?.node_name || m.compute_node_name,
},
],
})
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,
},
})
// Step 2: 统一通过推理任务加载接口异步派发模型加载,状态会落到列表记录中。
startupStatus.value = '正在启动模型服务,首次加载可能需要数分钟...'
const loadResult: any = await loadCompare(taskId)
if (loadResult?.status === 'failed' || loadResult?.error) {
ElMessage.warning(`模型加载失败:${loadResult?.error || '请检查算力节点日志'}`)
router.push('/model-inference')
return
}
// 加载为异步派发,回到列表页可看到“启动中 → 已就绪”的状态流转
ElMessage.success('模型加载中,就绪后即可对话')
router.push('/model-inference')
} 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 },
})
ElMessage.warning(`推理服务启动失败:${reason}`)
} finally {
submitting.value = false
startupStatus.value = ''
@@ -181,7 +177,10 @@ async function loadData() {
gpus.value = sys?.gpu || []
computeNodes.value = nodes || []
// 默认选中第一个空闲 GPU
if (idleGpus.value.length > 0) form.gpu_id = idleGpus.value[0].id ?? 0
if (idleGpus.value.length > 0) {
const firstGpu = idleGpus.value[0]
form.gpu_key = `${firstGpu.node_id || ''}:${firstGpu.id ?? 0}`
}
} catch {
// ignore
}
@@ -229,12 +228,12 @@ onMounted(loadData)
</el-form-item>
<el-form-item label="GPU">
<el-select v-model="form.gpu_id" style="width: 400px">
<el-select v-model="form.gpu_key" style="width: 400px">
<el-option
v-for="g in idleGpus"
:key="g.id ?? 0"
:label="`${g.name} (GPU${g.id ?? 0}) [空闲]`"
:value="g.id ?? 0"
:key="`${g.node_id || ''}:${g.id ?? 0}`"
:label="`${g.node_name || g.node_code || '算力节点'} / ${g.name} (GPU${g.id ?? 0}) [空闲]`"
:value="`${g.node_id || ''}:${g.id ?? 0}`"
/>
</el-select>
</el-form-item>

View File

@@ -92,11 +92,9 @@ async function handleUnload(row: any) {
loadData()
}
/** 删除(先释放算力节点再删除记录) */
/** 删除(后端删除内部会 best-effort 释放算力节点,这里直接删记录) */
async function handleDelete(row: any) {
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)

View File

@@ -18,9 +18,12 @@ const trainedModels = ref<TrainedModel[]>([])
const currentModel = computed(() => trainedModels.value.find((m) => m.name === modelName.value))
const form = reactive({
trained_model_id: '',
model_name: modelName.value,
train_method: method.value,
base_model_path: '',
adapter_path: '',
compute_node_id: '',
})
async function loadModel() {
@@ -28,23 +31,30 @@ async function loadModel() {
const res = await getTrainedModels()
trainedModels.value = res?.models || []
const target = trainedModels.value.find((m) => m.name === modelName.value)
form.trained_model_id = target?.id == null ? '' : String(target.id)
form.base_model_path = target?.base_model_path || ''
form.adapter_path = target?.artifact_dir || target?.adapter_path || target?.merged_path || ''
form.compute_node_id = target?.compute_node_id || ''
} catch {
// ignore
}
}
async function handleMerge() {
if (!form.model_name || !form.base_model_path) {
if (!form.model_name || !form.base_model_path || !form.adapter_path) {
ElMessage.warning('缺少模型信息')
return
}
merging.value = true
try {
await mergeModel({
trained_model_id: form.trained_model_id || form.model_name,
model_name: form.model_name,
train_method: form.train_method,
base_model_path: form.base_model_path,
adapter_path: form.adapter_path,
compute_node_id: form.compute_node_id,
output_model_name: `${form.model_name}-merged`,
})
ElMessage.success('合并成功')
router.push('/model-manage')
@@ -82,6 +92,9 @@ onMounted(loadModel)
<el-form-item label="基座模型路径">
<el-input v-model="form.base_model_path" placeholder="基座模型路径" />
</el-form-item>
<el-form-item label="Adapter 路径">
<el-input v-model="form.adapter_path" placeholder="LoRA Adapter 权重目录" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="merging" @click="handleMerge">

View File

@@ -99,23 +99,23 @@ onMounted(() => {
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建项目</el-button>
</template>
<template #columns>
<el-table-column prop="name" label="项目名" min-width="140" />
<el-table-column prop="code" label="编码ID" min-width="100" />
<el-table-column prop="name" label="项目名" min-width="140" />
<el-table-column prop="code" label="编码 ID" min-width="100" />
<el-table-column prop="status" label="状态" min-width="100" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
</template>
<template #actions="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
<el-button link type="danger" @click="handleDelete(asProject(row))">删除</el-button>
</template>
</DataTablePage>
<el-dialog v-model="showCreate" title="新建项目" width="520px">
<el-form label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="项目名" />
<el-input v-model="form.name" placeholder="项目名" />
</el-form-item>
<el-form-item label="编码ID" required>
<el-form-item label="编码 ID" required>
<el-select v-model="form.tenant_id" style="width: 100%" placeholder="选择租户编码">
<el-option v-for="t in tenantCodeOptions" :key="t.value" :label="t.label" :value="t.tenantId" />
</el-select>

View File

@@ -8,13 +8,14 @@ import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
import { usePolling } from '@/composables/usePolling'
import '@/plugins/echarts-training-log'
import { useModelsStore } from '@/stores/models'
import { getFineTune, getFineTuneDiagnostics, getFineTuneLogs, type TrainingDiagnostic } from '@/api/modules/fineTune'
import { getFineTune, getFineTuneDiagnostics, getFineTuneLogs, getFineTuneMetrics, type TrainingDiagnostic } from '@/api/modules/fineTune'
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
import { getDataset } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
import { TRAIN_TYPE_MAP, TRAIN_METHOD_MAP } from '@/constants'
import {
buildMetricChartOption,
metricsFromApi,
parseTrainingLog,
resolveTrainingLogFile,
} from './training-log/trainingLogModel'
@@ -42,6 +43,7 @@ const loading = ref(true)
// 训练指标数据ECharts 接收 number[],下标即 step
const metricData = reactive({
steps: [] as number[],
loss: [] as number[],
gradNorm: [] as number[],
lr: [] as number[],
@@ -62,9 +64,9 @@ const gpuExpanded = ref(false)
let refreshInFlight = false
/** 三个曲线的 ECharts 配置(响应式,数据变化自动重绘) */
const lossChartOption = computed(() => buildMetricChartOption('Loss', metricData.loss, '#4f46e5'))
const gradChartOption = computed(() => buildMetricChartOption('Grad Norm', metricData.gradNorm, '#3b82f6'))
const lrChartOption = computed(() => buildMetricChartOption('Learning Rate', metricData.lr, '#14b8a6', true))
const lossChartOption = computed(() => buildMetricChartOption('Loss', metricData.loss, metricData.steps, '#4f46e5'))
const gradChartOption = computed(() => buildMetricChartOption('Grad Norm', metricData.gradNorm, metricData.steps, '#3b82f6'))
const lrChartOption = computed(() => buildMetricChartOption('Learning Rate', metricData.lr, metricData.steps, '#14b8a6', true))
const baseModelName = computed(() => task.value?.base_model != null
? modelsStore.getModelName(task.value.base_model)
: '未配置')
@@ -77,10 +79,10 @@ const trainingMethodName = computed(() => task.value?.train_method
const taskGpuLabel = computed(() => task.value?.gpus?.length
? task.value.gpus.map((gpuId) => `GPU ${gpuId}`).join('、')
: '未配置')
const latestLoss = computed(() => metricData.loss[metricData.loss.length - 1])
const latestGradNorm = computed(() => metricData.gradNorm[metricData.gradNorm.length - 1])
const latestLearningRate = computed(() => metricData.lr[metricData.lr.length - 1])
const latestEpoch = computed(() => metricData.epoch[metricData.epoch.length - 1])
const latestLoss = computed(() => lastFinite(metricData.loss))
const latestGradNorm = computed(() => lastFinite(metricData.gradNorm))
const latestLearningRate = computed(() => lastFinite(metricData.lr))
const latestEpoch = computed(() => lastFinite(metricData.epoch))
const logLineCount = computed(() => logContent.value ? logContent.value.split(/\r?\n/).length : 0)
const taskGpuItems = computed<TaskGpuItem[]>(() => (task.value?.gpus ?? []).map((gpuId) => {
const index = Number(gpuId)
@@ -123,7 +125,7 @@ const gpuRefreshState = computed(() => {
})
return gpuLoadError.value
? `更新失败 · 最后更新 ${updateTime}`
: `${updateTime} 更新 · 每 5 秒刷新`
: `${updateTime} 更新 · 每 3 秒刷新`
})
function formatMetric(value?: number, scientific = false) {
@@ -131,6 +133,13 @@ function formatMetric(value?: number, scientific = false) {
return scientific ? value.toExponential(2) : value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
}
function lastFinite(values: number[]) {
for (let index = values.length - 1; index >= 0; index -= 1) {
if (Number.isFinite(values[index])) return values[index]
}
return undefined
}
function safePercent(value?: number) {
return Math.round(Math.min(100, Math.max(0, Number(value || 0))))
}
@@ -216,6 +225,7 @@ const isLoraMethod = computed(() =>
function applyLogContent(content: string) {
const parsed = parseTrainingLog(content)
logContent.value = content
metricData.steps = parsed.metrics.steps
metricData.loss = parsed.metrics.loss
metricData.gradNorm = parsed.metrics.gradNorm
metricData.lr = parsed.metrics.lr
@@ -223,6 +233,26 @@ function applyLogContent(content: string) {
Object.assign(summary, parsed.summary)
}
function applyMetricData(metrics = { steps: [] as number[], loss: [] as number[], gradNorm: [] as number[], lr: [] as number[], epoch: [] as number[] }) {
metricData.steps = metrics.steps
metricData.loss = metrics.loss
metricData.gradNorm = metrics.gradNorm
metricData.lr = metrics.lr
metricData.epoch = metrics.epoch
}
async function loadMetrics(currentTask: FineTuneTask) {
try {
const points = await getFineTuneMetrics(currentTask.id)
const parsed = metricsFromApi(points || [])
if (parsed.loss.length || parsed.gradNorm.length || parsed.lr.length) {
applyMetricData(parsed)
}
} catch {
// 日志解析结果会作为兜底曲线数据。
}
}
async function loadLog(currentTask: FineTuneTask) {
try {
const runtime = await getFineTuneLogs(currentTask.id, { tail_lines: 800 })
@@ -277,6 +307,7 @@ async function refreshAll() {
? loadDataset(currentTask.train_dataset_id)
: Promise.resolve()
await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus(), loadDiagnostics(currentTask)])
await loadMetrics(currentTask)
} finally {
loading.value = false
refreshInFlight = false
@@ -523,7 +554,7 @@ onMounted(async () => {
<!-- 训练曲线 -->
<PageCard class="metrics-panel" title="训练曲线" subtitle="持续监控模型收敛情况与学习率变化">
<template #extra><span class="refresh-state"> 5 秒刷新</span></template>
<template #extra><span class="refresh-state"> 3 秒刷新</span></template>
<div class="chart-list" aria-label="训练指标曲线">
<section class="chart-section">
<div class="chart-section-header">
@@ -560,7 +591,7 @@ onMounted(async () => {
<!-- 原始日志 -->
<PageCard class="log-card" title="训练日志" subtitle="查看训练任务的原始运行输出">
<template #extra><span class="log-meta">{{ logLineCount }} · 5 秒刷新</span></template>
<template #extra><span class="log-meta">{{ logLineCount }} · 3 秒刷新</span></template>
<pre class="log-pre">{{ logContent || '暂无日志' }}</pre>
</PageCard>
</template>

View File

@@ -1,7 +1,9 @@
import type { EChartsOption } from 'echarts'
import type { FineTuneTask, TrainingLogFile } from '@/types'
import type { FineTuneMetricPoint } from '@/api/modules/fineTune'
export interface TrainingMetricData {
steps: number[]
loss: number[]
gradNorm: number[]
lr: number[]
@@ -26,7 +28,7 @@ function escapeRegExp(value: string) {
}
function extractNumber(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*:\\s*(${NUMBER_SOURCE})`, 'i'))
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*(?:=|:)\\s*(${NUMBER_SOURCE})`, 'i'))
return match ? Number(match[1]) : undefined
}
@@ -57,24 +59,44 @@ export function resolveTrainingLogFile(
/** 解析日志中的逐步指标。字段顺序和常见数值格式均不受限制。 */
export function parseTrainingMetrics(text: string): TrainingMetricData {
const metrics: TrainingMetricData = { loss: [], gradNorm: [], lr: [], epoch: [] }
const blocks = text.match(/\{[^{}\r\n]*\}/g) || []
const metrics: TrainingMetricData = { steps: [], loss: [], gradNorm: [], lr: [], epoch: [] }
const candidates = text
.split(/\r?\n/)
.flatMap((line) => {
const blocks = line.match(/\{[^{}\r\n]*\}/g)
return blocks?.length ? blocks.map((block) => `${line} ${block}`) : [line]
})
for (const block of blocks) {
const loss = extractNumber(block, 'loss')
const gradNorm = extractNumber(block, 'grad_norm')
const learningRate = extractNumber(block, 'learning_rate')
const epoch = extractNumber(block, 'epoch')
if (loss == null || gradNorm == null || learningRate == null) continue
metrics.loss.push(loss)
metrics.gradNorm.push(gradNorm)
metrics.lr.push(learningRate)
if (epoch != null) metrics.epoch.push(epoch)
for (const [index, line] of candidates.entries()) {
const loss = extractNumber(line, 'loss')
const gradNorm = extractNumber(line, 'grad_norm')
const learningRate = extractNumber(line, 'learning_rate')
const epoch = extractNumber(line, 'epoch')
if (loss == null && gradNorm == null && learningRate == null) continue
metrics.steps.push(extractNumber(line, 'step') ?? metrics.steps.length + index + 1)
metrics.loss.push(loss ?? Number.NaN)
metrics.gradNorm.push(gradNorm ?? Number.NaN)
metrics.lr.push(learningRate ?? Number.NaN)
metrics.epoch.push(epoch ?? Number.NaN)
}
return metrics
}
export function metricsFromApi(points: FineTuneMetricPoint[]): TrainingMetricData {
const metrics: TrainingMetricData = { steps: [], loss: [], gradNorm: [], lr: [], epoch: [] }
for (const [index, point] of points.entries()) {
const hasMetric = point.loss != null || point.grad_norm != null || point.learning_rate != null
if (!hasMetric) continue
metrics.steps.push(Number(point.step || index + 1))
metrics.loss.push(point.loss == null ? Number.NaN : Number(point.loss))
metrics.gradNorm.push(point.grad_norm == null ? Number.NaN : Number(point.grad_norm))
metrics.lr.push(point.learning_rate == null ? Number.NaN : Number(point.learning_rate))
metrics.epoch.push(point.epoch == null ? Number.NaN : Number(point.epoch))
}
return metrics
}
/** 每次都返回新对象,日志截断或切换时不会残留上一轮汇总。 */
export function parseTrainingSummary(text: string): TrainingSummary {
const emptySummary: TrainingSummary = { epoch: '', trainLoss: '', runtime: '' }
@@ -102,11 +124,23 @@ export function parseTrainingLog(text: string): ParsedTrainingLog {
export function buildMetricChartOption(
label: string,
data: number[],
steps: number[],
color: string,
logScale = false,
): EChartsOption {
const visibleData = data.map((value) => (Number.isFinite(value) ? value : null))
return {
grid: { top: 24, right: 20, bottom: 56, left: 56 },
graphic: visibleData.some((value) => value != null)
? []
: [
{
type: 'text',
left: 'center',
top: 'middle',
style: { text: '暂无训练指标数据', fill: '#94a3b8', fontSize: 13 },
},
],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
@@ -116,6 +150,7 @@ export function buildMetricChartOption(
},
xAxis: {
type: 'category',
data: steps.map((step, index) => (Number.isFinite(step) ? String(step) : String(index + 1))),
boundaryGap: false,
name: 'Step',
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
@@ -142,7 +177,7 @@ export function buildMetricChartOption(
{
name: label,
type: 'line',
data,
data: visibleData,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color },

View File

@@ -30,7 +30,7 @@ function formatQuota(quota: Record<string, unknown> | undefined | null) {
if (q.gpu > 0) parts.push(`GPU ${q.gpu}`)
if (q.storage > 0) parts.push(`存储 ${q.storage}GB`)
if (q.maxProjects > 0) parts.push(`项目 ${q.maxProjects}`)
return parts.length ? parts.join(' | ') : ''
return parts.length ? parts.join(' | ') : '-'
}
async function load() {
@@ -52,7 +52,7 @@ function asTenant(row: unknown): Tenant {
function quotaText(row: unknown): string {
const quota = asTenant(row).quota || {}
return Object.keys(quota).length ? JSON.stringify(quota) : ''
return Object.keys(quota).length ? JSON.stringify(quota) : '-'
}
async function submitCreate() {
@@ -118,13 +118,13 @@ onMounted(load)
</template>
<template #columns>
<el-table-column prop="name" label="租户名称" min-width="140" />
<el-table-column prop="code" label="用户ID" min-width="100" />
<el-table-column prop="code" label="租户 ID" min-width="100" />
<el-table-column prop="status" label="状态" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
</template>
<template #actions="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
<el-button link type="danger" @click="handleDelete(asTenant(row))">删除</el-button>
</template>
</DataTablePage>