perf: 模型管理界面重做

This commit is contained in:
2026-03-11 20:33:04 +08:00
parent 219fa566ef
commit 373d7f8201
37 changed files with 4120 additions and 2108 deletions

View File

@@ -0,0 +1,774 @@
<script setup lang="ts">
import type {llmType} from '#/api';
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
import {getDefaultModelAbility} from '#/views/ai/model/modelUtils/model-ability';
import {computed, onMounted, reactive, ref} from 'vue';
import {CircleCheck, CircleClose, Delete, Edit, Loading, Select,} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import {deleteLlm, getModelList, verifyModelConfig,} from '#/api/ai/llm';
import {$t} from '#/locales';
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
import {mapLlmToModelAbility} from '#/views/ai/model/modelUtils/model-ability-utils';
interface ProviderOption {
id: string;
providerName: string;
}
interface FilterState {
keyword: string;
modelType: string;
providerId: string;
}
interface BatchDeleteResult {
failed: Array<{ id: string; message: string }>;
successIds: string[];
}
const props = defineProps<{
providers: ProviderOption[];
}>();
const emit = defineEmits<{
(e: 'createModel', modelType?: string): void;
(e: 'editModel', id: string): void;
(e: 'refreshProviderStats'): void;
}>();
const isLoading = ref(false);
const isActionLoading = ref(false);
const modelRows = ref<llmType[]>([]);
const selectedRows = ref<llmType[]>([]);
const lastErrorMessage = ref('');
type VerifyButtonStatus = 'error' | 'idle' | 'loading' | 'success';
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
const filterState = reactive<FilterState>({
keyword: '',
modelType: '',
providerId: '',
});
const modelTypeOptions = [
{
label: $t('llmProvider.chatModel'),
value: 'chatModel',
},
{
label: $t('llmProvider.embeddingModel'),
value: 'embeddingModel',
},
{
label: $t('llmProvider.rerankModel'),
value: 'rerankModel',
},
];
const modelTypeLabelMap: Record<string, string> = {
chatModel: $t('llmProvider.chatModel'),
embeddingModel: $t('llmProvider.embeddingModel'),
rerankModel: $t('llmProvider.rerankModel'),
};
const getProviderName = (row: llmType) =>
row.modelProvider?.providerName || row.aiLlmProvider?.providerName || '-';
const getProviderType = (row: llmType) =>
row.modelProvider?.providerType || row.aiLlmProvider?.providerType || '';
const getProviderIcon = (row: llmType) =>
row.modelProvider?.icon || row.aiLlmProvider?.icon || '';
const getModelName = (row: llmType) =>
row.llmModel || (row as any).modelName || '-';
const getModelTypeLabel = (row: llmType) =>
modelTypeLabelMap[row.modelType] || row.modelType || '-';
const canVerify = (row: llmType) => row.modelType !== 'rerankModel';
const getModelId = (row: llmType) =>
String((row as any).id || (row as any).llmId || (row as any).modelId || '');
const getVerifyStatus = (row: llmType): VerifyButtonStatus =>
verifyStatusMap.value[getModelId(row)] || 'idle';
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
if (!id) {
return;
}
verifyStatusMap.value[id] = status;
};
const isVerifying = (row: llmType) => getVerifyStatus(row) === 'loading';
const getVerifyButtonText = (row: llmType) => {
const status = getVerifyStatus(row);
if (status === 'loading') {
return '验证中';
}
if (status === 'success') {
return '验证成功';
}
if (status === 'error') {
return '验证失败';
}
return '验证配置';
};
const getVerifyButtonIcon = (row: llmType) => {
const status = getVerifyStatus(row);
if (status === 'loading') {
return Loading;
}
if (status === 'success') {
return CircleCheck;
}
if (status === 'error') {
return CircleClose;
}
return Select;
};
const getAbilityTags = (row: llmType): ModelAbilityItem[] =>
mapLlmToModelAbility(row, getDefaultModelAbility()).filter(
(tag) => tag.selected,
);
const totalCount = computed(() => modelRows.value.length);
const filteredRows = computed(() => {
const keyword = filterState.keyword.trim().toLowerCase();
return modelRows.value.filter((item) => {
if (
filterState.providerId &&
String((item as any).providerId) !== filterState.providerId
) {
return false;
}
if (filterState.modelType && item.modelType !== filterState.modelType) {
return false;
}
if (!keyword) {
return true;
}
const searchTargets = [
item.title,
getModelName(item),
item.groupName,
getProviderName(item),
]
.filter(Boolean)
.map((text) => String(text).toLowerCase());
return searchTargets.some((text) => text.includes(keyword));
});
});
const selectableRowCount = computed(() => selectedRows.value.length);
const loadModels = async () => {
isLoading.value = true;
lastErrorMessage.value = '';
try {
const res = await getModelList();
if (res.errorCode === 0) {
modelRows.value = res.data || [];
verifyStatusMap.value = {};
} else {
modelRows.value = [];
verifyStatusMap.value = {};
lastErrorMessage.value =
res.message || $t('ui.actionMessage.operationFailed');
}
} catch (error) {
modelRows.value = [];
verifyStatusMap.value = {};
lastErrorMessage.value =
(error as Error)?.message || $t('ui.actionMessage.operationFailed');
} finally {
isLoading.value = false;
}
};
const reloadAndNotify = async () => {
await loadModels();
emit('refreshProviderStats');
selectedRows.value = [];
};
const handleSelectionChange = (rows: llmType[]) => {
selectedRows.value = rows;
};
const handleEdit = (row: llmType) => {
const modelId = getModelId(row);
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法编辑');
return;
}
emit('editModel', modelId);
};
const handleDelete = async (row: llmType) => {
const modelId = getModelId(row);
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法删除');
return;
}
try {
await ElMessageBox.confirm(
`确认删除模型「${row.title}」吗?该操作不可恢复。`,
$t('message.noticeTitle'),
{
cancelButtonText: $t('message.cancel'),
confirmButtonText: $t('message.ok'),
type: 'warning',
},
);
} catch {
return;
}
isActionLoading.value = true;
try {
const res = await deleteLlm({ id: modelId });
if (res.errorCode === 0) {
ElMessage.success(res.message || '模型已删除');
await reloadAndNotify();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} finally {
isActionLoading.value = false;
}
};
const handleVerify = async (row: llmType) => {
const modelId = getModelId(row);
if (!canVerify(row) || !modelId || isVerifying(row)) {
if (!modelId) {
ElMessage.warning('当前模型缺少ID无法验证配置');
}
return;
}
setVerifyStatus(modelId, 'loading');
try {
const res = await verifyModelConfig(modelId);
if (res.errorCode === 0) {
setVerifyStatus(modelId, 'success');
if (row.modelType === 'embeddingModel' && res?.data?.dimension) {
ElMessage.success(`验证成功,向量维度:${res.data.dimension}`);
} else {
ElMessage.success('验证成功');
}
} else {
setVerifyStatus(modelId, 'error');
if (!res.message) {
ElMessage.error($t('ui.actionMessage.operationFailed'));
}
}
} catch {
setVerifyStatus(modelId, 'error');
// error toast is already handled by global response interceptors
} finally {
// keep final status to show explicit success/failure state
}
};
const runBatchDelete = async (
ids: string[],
concurrency = 5,
): Promise<BatchDeleteResult> => {
const queue = [...ids];
const successIds: string[] = [];
const failed: Array<{ id: string; message: string }> = [];
const worker = async () => {
while (queue.length > 0) {
const id = queue.shift();
if (!id) {
return;
}
try {
const res = await deleteLlm({ id });
if (res.errorCode === 0) {
successIds.push(id);
} else {
failed.push({ id, message: res.message || '删除失败' });
}
} catch (error) {
failed.push({ id, message: (error as Error)?.message || '网络错误' });
}
}
};
const workerCount = Math.max(1, Math.min(concurrency, ids.length || 1));
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return {
failed,
successIds,
};
};
const handleBatchDelete = async () => {
const ids = selectedRows.value
.map((item) => getModelId(item))
.filter(Boolean);
if (ids.length === 0) {
ElMessage.warning('请先选择要删除的模型');
return;
}
try {
await ElMessageBox.confirm(
`确认批量删除 ${ids.length} 个模型吗?该操作不可恢复。`,
$t('message.noticeTitle'),
{
cancelButtonText: $t('message.cancel'),
confirmButtonText: $t('message.ok'),
type: 'warning',
},
);
} catch {
return;
}
isActionLoading.value = true;
try {
const result = await runBatchDelete(ids, 5);
const successCount = result.successIds.length;
const failCount = result.failed.length;
if (failCount === 0) {
ElMessage.success(`批量删除完成,共 ${successCount} 个模型`);
} else {
ElMessage.warning(
`批量删除完成,成功 ${successCount} 个,失败 ${failCount}`,
);
}
await reloadAndNotify();
} finally {
isActionLoading.value = false;
}
};
const resetFilters = () => {
filterState.keyword = '';
filterState.providerId = '';
filterState.modelType = '';
};
onMounted(loadModels);
defineExpose({
async reloadData() {
await loadModels();
},
});
</script>
<template>
<section class="active-workspace">
<header class="active-workspace__header">
<div class="active-workspace__summary">
<h3>已配置模型</h3>
<p> {{ totalCount }} 个模型</p>
</div>
</header>
<div class="active-workspace__filters">
<ElInput
v-model.trim="filterState.keyword"
clearable
placeholder="搜索模型名、模型ID、服务商、分组"
/>
<ElSelect
v-model="filterState.providerId"
clearable
placeholder="全部服务商"
>
<ElOption
v-for="provider in props.providers"
:key="provider.id"
:label="provider.providerName"
:value="provider.id"
/>
</ElSelect>
<ElSelect
v-model="filterState.modelType"
clearable
placeholder="全部模型类型"
>
<ElOption
v-for="type in modelTypeOptions"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</ElSelect>
<ElButton @click="resetFilters">重置</ElButton>
</div>
<div v-if="selectableRowCount > 0" class="active-workspace__batch-bar">
<span>已选 {{ selectableRowCount }} </span>
<div class="active-workspace__batch-actions">
<ElButton
class="is-danger"
:disabled="isActionLoading"
@click="handleBatchDelete"
>
批量删除
</ElButton>
</div>
</div>
<div v-if="isLoading" class="active-workspace__state">
正在加载模型数据...
</div>
<div v-else-if="lastErrorMessage" class="active-workspace__state is-error">
{{ lastErrorMessage }}
</div>
<div v-else-if="modelRows.length === 0" class="active-workspace__empty">
<ElEmpty description="还没有模型,先添加一个模型开始使用。">
<ElButton type="primary" @click="emit('createModel', 'chatModel')">
添加模型
</ElButton>
</ElEmpty>
</div>
<div v-else-if="filteredRows.length === 0" class="active-workspace__empty">
<ElEmpty description="没有符合筛选条件的模型,试试调整筛选项。" />
</div>
<ElTable
v-else
row-key="id"
:data="filteredRows"
class="active-workspace__table"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" />
<ElTableColumn label="模型名称" min-width="220">
<template #default="{ row }">
<div class="active-workspace__name-with-logo">
<ModelProviderBadge
:icon="getProviderIcon(row)"
:provider-name="getProviderName(row)"
:provider-type="getProviderType(row)"
:size="30"
/>
<div class="active-workspace__name-cell">
<strong>{{ row.title }}</strong>
<span>{{ getModelName(row) }}</span>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="服务商" min-width="140">
<template #default="{ row }">
{{ getProviderName(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="类型" width="110">
<template #default="{ row }">
{{ getModelTypeLabel(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="分组" min-width="120">
<template #default="{ row }">
{{ row.groupName || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="能力" min-width="220">
<template #default="{ row }">
<div class="active-workspace__ability">
<ElTag
v-for="tag in getAbilityTags(row)"
:key="tag.value"
effect="plain"
size="small"
>
{{ tag.label }}
</ElTag>
<span v-if="getAbilityTags(row).length === 0">-</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" min-width="290" fixed="right">
<template #default="{ row }">
<div class="active-workspace__actions">
<ElButton
v-if="canVerify(row)"
text
size="small"
class="active-workspace__verify-btn"
:class="`is-${getVerifyStatus(row)}`"
:disabled="
isVerifying(row) || isActionLoading || !getModelId(row)
"
@click="handleVerify(row)"
>
<template #icon>
<ElIcon
class="active-workspace__verify-icon"
:class="`is-${getVerifyStatus(row)}`"
>
<component :is="getVerifyButtonIcon(row)" />
</ElIcon>
</template>
{{ getVerifyButtonText(row) }}
</ElButton>
<ElButton text size="small" :icon="Edit" @click="handleEdit(row)">
编辑
</ElButton>
<ElButton
text
size="small"
class="is-danger"
:icon="Delete"
@click="handleDelete(row)"
>
删除
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</section>
</template>
<style scoped>
.active-workspace {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.active-workspace__header {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid hsl(var(--divider-faint) / 58%);
}
.active-workspace__summary h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.active-workspace__summary p,
.active-workspace__state {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-muted));
}
.active-workspace__filters {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(0, 1fr)) auto;
gap: 12px;
}
.active-workspace__batch-bar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: hsl(var(--surface-contrast-soft) / 68%);
border: 1px solid hsl(var(--divider-faint) / 58%);
border-radius: 12px;
}
.active-workspace__batch-bar span {
font-size: 13px;
color: hsl(var(--text-muted));
}
.active-workspace__batch-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.active-workspace__batch-actions .is-danger,
.active-workspace__actions .is-danger {
color: hsl(var(--destructive));
}
.active-workspace__state {
padding: 14px 0;
}
.active-workspace__state.is-error {
color: hsl(var(--destructive));
}
.active-workspace__empty {
display: flex;
flex: 1;
}
.active-workspace__empty :deep(.el-empty) {
margin: auto;
}
.active-workspace__table {
flex: 1;
}
.active-workspace__name-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.active-workspace__name-with-logo {
display: flex;
gap: 10px;
align-items: center;
}
.active-workspace__name-cell strong {
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.active-workspace__name-cell span {
font-size: 12px;
color: hsl(var(--text-muted));
}
.active-workspace__ability {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.active-workspace__actions {
display: inline-flex;
gap: 4px;
align-items: center;
}
.active-workspace__verify-btn.is-idle {
color: hsl(var(--text-muted));
}
.active-workspace__verify-btn.is-loading {
color: hsl(var(--primary));
}
.active-workspace__verify-btn.is-success {
color: hsl(var(--success));
}
.active-workspace__verify-btn.is-error {
color: hsl(var(--destructive));
}
.active-workspace__verify-icon {
transition:
color 0.24s ease,
transform 0.24s ease;
}
.active-workspace__verify-icon.is-loading {
animation: active-workspace-verify-spin 0.9s linear infinite;
}
.active-workspace__verify-icon.is-success,
.active-workspace__verify-icon.is-error {
animation: active-workspace-verify-pop 0.32s ease;
}
@keyframes active-workspace-verify-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes active-workspace-verify-pop {
0% {
opacity: 0.72;
transform: scale(0.82);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1024px) {
.active-workspace__filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.active-workspace__header {
flex-direction: column;
}
.active-workspace__filters {
grid-template-columns: minmax(0, 1fr);
}
.active-workspace__batch-bar {
flex-direction: column;
align-items: flex-start;
}
}
</style>