feat: 新增 Agent 管理面板 + ModelManageView

- 新增 AgentManagePanel 组件,集中管理 Agent 实例
- ModelConfigView 重命名为 ModelProviderManagePanel
- 新增 ModelManageView 页面整合模型与 Agent 管理
- 更新路由、侧边栏导航
- HomeView 调整布局,新增 Agent 管理入口
This commit is contained in:
Wenjie Zhang 2026-05-24 00:47:15 +08:00
parent 749ed8d757
commit a35fd85353
7 changed files with 1223 additions and 264 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,14 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
Globe,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
CheckCircle2,
Edit3
} from 'lucide-vue-next'
import { Globe, Plus, RefreshCw, Search, Settings2, Trash2, CheckCircle2, Edit3 } from 'lucide-vue-next'
import { modelProviderApi } from '@/apis/system_api'
import { modelIcons } from '@/utils/modelIcon'
import PageHeader from '@/components/shared/PageHeader.vue'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
// ============ State ============
const loading = ref(false)
const remoteLoading = ref(false)
const saving = ref(false)
@ -78,8 +67,6 @@ const remoteModelsLoaded = ref({})
// Remote model search state per provider
const remoteModelSearch = ref({})
const remoteModelTypeFilter = ref({})
// ============ Computed ============
const filteredProviders = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
const filtered = keyword
@ -234,8 +221,6 @@ const parseJsonObject = (text, label) => {
}
const formatJsonText = (value) => JSON.stringify(value || {}, null, 2)
// ============ Provider Operations ============
const loadProviders = async () => {
loading.value = true
try {
@ -251,7 +236,7 @@ const loadProviders = async () => {
function getProviderInfo(provider) {
return [
{ label: 'Base URL', value: provider.base_url || '-' },
{ label: '类型', value: provider.provider_type || 'openai' },
{ label: '能力', value: provider.capabilities?.join(', ') || 'chat' },
{ label: '已启用', value: `${provider.enabled_models?.length || 0} 个模型` }
]
}
@ -564,76 +549,65 @@ const removeModel = async (providerId, modelId) => {
})
}
// ============ Lifecycle ============
onMounted(loadProviders)
defineExpose({
loading,
stats: providerStats,
refresh: loadProviders
})
</script>
<template>
<div class="model-config-view">
<!-- Page Header -->
<PageHeader title="模型配置" :show-border="true">
<template #info>
<div class="summary-strip">
<span>{{ providerStats.total }} 个供应商</span>
<span>{{ providerStats.enabled }} 个启用</span>
<span v-if="providerStats.warning > 0" class="warning-count"
>{{ providerStats.warning }} 个凭证缺失</span
>
<span>{{ providerStats.models }} 个模型</span>
</div>
</template>
</PageHeader>
<!-- Toolbar -->
<PageShoulder v-model:search="searchQuery" search-placeholder="搜索供应商...">
<template #actions>
<a-button type="primary" class="lucide-icon-btn" @click="openCreateProviderModal">
<Plus :size="14" />
新增供应商
</a-button>
<a-button class="lucide-icon-btn" @click="loadProviders" :loading="loading">
<RefreshCw :size="14" :class="{ spinning: loading }" />
</a-button>
</template>
</PageShoulder>
<!-- Provider Card Grid -->
<ExtensionCardGrid :min-width="320">
<InfoCard
v-for="provider in filteredProviders"
:key="provider.provider_id"
:title="provider.display_name"
:subtitle="provider.provider_id"
:disabled="!provider.is_enabled"
:default-icon="Globe"
:info="getProviderInfo(provider)"
:status="getProviderStatus(provider)"
@click="openEditProviderModal(provider)"
>
<template #icon>
<img
v-if="getProviderIcon(provider)"
:src="getIconUrl(getProviderIcon(provider))"
:alt="provider.display_name"
/>
</template>
<template #footer>
<button class="view-models-btn" type="button" @click.stop="openModelsModal(provider)">
<Settings2 :size="14" />
管理模型
</button>
<a-tooltip title="编辑供应商">
<a-button
size="small"
class="lucide-icon-btn"
@click.stop="openEditProviderModal(provider)"
>
<Edit3 :size="14" />
<div class="model-provider-manage-panel">
<PageShoulder v-model:search="searchQuery" search-placeholder="搜索供应商...">
<template #actions>
<a-button type="primary" class="lucide-icon-btn" @click="openCreateProviderModal">
<Plus :size="14" />
新增供应商
</a-button>
</a-tooltip>
</template>
</InfoCard>
</ExtensionCardGrid>
<a-button class="lucide-icon-btn" @click="loadProviders" :loading="loading">
<RefreshCw :size="14" :class="{ spinning: loading }" />
</a-button>
</template>
</PageShoulder>
<ExtensionCardGrid :min-width="320">
<InfoCard
v-for="provider in filteredProviders"
:key="provider.provider_id"
:title="provider.display_name"
:subtitle="provider.provider_id"
:disabled="!provider.is_enabled"
:default-icon="Globe"
:info="getProviderInfo(provider)"
:status="getProviderStatus(provider)"
@click="openEditProviderModal(provider)"
>
<template #icon>
<img
v-if="getProviderIcon(provider)"
:src="getIconUrl(getProviderIcon(provider))"
:alt="provider.display_name"
/>
</template>
<template #footer>
<button class="view-models-btn" type="button" @click.stop="openModelsModal(provider)">
<Settings2 :size="14" />
管理模型
</button>
<a-tooltip title="编辑供应商">
<a-button
size="small"
class="lucide-icon-btn"
@click.stop="openEditProviderModal(provider)"
>
<Edit3 :size="14" />
</a-button>
</a-tooltip>
</template>
</InfoCard>
</ExtensionCardGrid>
<!-- Provider Edit Modal -->
<a-modal
@ -791,7 +765,6 @@ onMounted(loadProviders)
</a-collapse>
</div>
</a-modal>
<!-- Models Management Modal -->
<a-modal
v-model:open="showModelsModal"
@ -957,7 +930,6 @@ onMounted(loadProviders)
</div>
</div>
</a-modal>
<!-- Model Config Modal -->
<a-modal
v-model:open="showModelModal"
@ -1022,38 +994,13 @@ onMounted(loadProviders)
</div>
</template>
<style lang="less" scoped>
.model-config-view {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--gray-0);
color: var(--gray-1000);
.model-provider-manage-panel {
height: 100%;
min-height: 0;
}
// ============ Provider Summary Strip ============
.summary-strip {
display: flex;
gap: 8px;
span {
padding: 6px 10px;
border: 1px solid var(--gray-100);
border-radius: 7px;
background: var(--gray-10);
color: var(--gray-700);
font-size: 12px;
line-height: 18px;
}
.warning-count {
background: var(--color-warning-50);
border-color: var(--color-warning-100);
color: var(--color-warning-700);
}
}
// ============ Provider Card Footer ============
.view-models-btn {
display: inline-flex;
align-items: center;
@ -1086,33 +1033,12 @@ onMounted(loadProviders)
}
}
.clickable {
color: var(--main-700);
cursor: pointer;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.card-actions {
display: flex;
gap: 4px;
}
// ============ Models Modal ============
.models-modal-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.models-modal-actions {
display: flex;
gap: 8px;
}
.models-section {
display: flex;
flex-direction: column;
@ -1126,12 +1052,6 @@ onMounted(loadProviders)
color: var(--gray-700);
}
.models-actions {
display: flex;
gap: 8px;
}
// ============ Models Table ============
.models-table {
display: flex;
flex-direction: column;
@ -1140,10 +1060,15 @@ onMounted(loadProviders)
overflow: hidden;
}
.table-head {
.table-head,
.table-row {
display: grid;
grid-template-columns: 1fr 80px 70px 60px 110px;
gap: 8px;
align-items: center;
}
.table-head {
padding: 10px 12px;
background: var(--gray-50);
font-size: 11px;
@ -1154,13 +1079,9 @@ onMounted(loadProviders)
}
.table-row {
display: grid;
grid-template-columns: 1fr 80px 70px 60px 110px;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--gray-100);
font-size: 13px;
align-items: center;
transition: background 0.1s;
&:hover {
@ -1196,14 +1117,6 @@ onMounted(loadProviders)
white-space: nowrap;
}
.col-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 12px;
}
.col-type {
display: flex;
align-items: center;
@ -1215,18 +1128,12 @@ onMounted(loadProviders)
font-size: 12px;
}
.col-status {
display: flex;
align-items: center;
}
.col-ops {
display: flex;
gap: 4px;
justify-content: flex-end;
}
// ============ Type Tags ============
.type-tag {
display: inline-flex;
align-items: center;
@ -1260,53 +1167,6 @@ onMounted(loadProviders)
}
}
// ============ Status Badges ============
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
&.active {
color: var(--color-success-700);
}
&.stale {
color: var(--color-warning-700);
}
}
// ============ Remote Section ============
.remote-section {
margin-top: 16px;
border: 1px solid var(--gray-150);
border-radius: 6px;
overflow: hidden;
}
.remote-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: var(--gray-50);
color: var(--gray-700);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: var(--gray-100);
}
}
.remote-list {
border-top: 1px solid var(--gray-100);
}
.enabled-header {
display: flex;
align-items: center;
@ -1345,6 +1205,10 @@ onMounted(loadProviders)
flex-shrink: 0;
}
.remote-list {
border-top: 1px solid var(--gray-100);
}
.remote-row {
display: flex;
align-items: center;
@ -1413,11 +1277,6 @@ onMounted(loadProviders)
}
}
.models-empty {
padding: 24px;
}
// ============ Modal Form ============
.modal-form {
display: flex;
flex-direction: column;
@ -1466,6 +1325,7 @@ onMounted(loadProviders)
flex-direction: column;
gap: 14px;
}
:deep(.ant-collapse-header) {
padding-inline: 0;
}
@ -1506,28 +1366,9 @@ onMounted(loadProviders)
}
}
// ============ Responsive ============
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 20px 16px;
}
.toolbar {
padding: 12px 16px;
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
.provider-grid {
.form-row {
grid-template-columns: 1fr;
padding: 16px;
}
.table-head,

View File

@ -145,8 +145,8 @@ const mainList = computed(() => {
})
items.push({
name: '模型配置',
path: '/model-config',
name: '模型管理',
path: '/model-manage',
icon: Box,
activeIcon: Box
})

View File

@ -78,15 +78,15 @@ const router = createRouter({
]
},
{
path: '/model-config',
name: 'model-config',
path: '/model-manage',
name: 'model-manage',
component: AppLayout,
children: [
{
path: '',
name: 'ModelConfigComp',
component: () => import('../views/ModelConfigView.vue'),
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
name: 'ModelManageComp',
component: () => import('../views/ModelManageView.vue'),
meta: { keepAlive: false, requiresAuth: true }
}
]
},

View File

@ -131,7 +131,6 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useInfoStore } from '@/stores/info'
import { useAgentStore } from '@/stores/agent'
import { healthApi } from '@/apis/system_api'
import UserInfoComponent from '@/components/UserInfoComponent.vue'
import {
@ -149,7 +148,6 @@ import {
const router = useRouter()
const userStore = useUserStore()
const infoStore = useInfoStore()
const agentStore = useAgentStore()
const repoUrl = 'https://github.com/xerrors/Yuxi'
const faqUrl = 'https://xerrors.github.io/Yuxi/'
@ -358,35 +356,13 @@ const retryLoad = () => {
}
const goToChat = async () => {
//
if (!userStore.isLoggedIn) {
// /agent
sessionStorage.setItem('redirect', '/') //
sessionStorage.setItem('redirect', '/')
router.push('/login')
return
}
//
if (userStore.isAdmin) {
//
await agentStore.initialize()
router.push('/agent')
return
}
//
try {
//
const defaultAgent = agentStore.defaultAgent
if (defaultAgent?.id) {
router.push(`/agent/${defaultAgent.id}`)
} else {
router.push('/agent')
}
} catch (error) {
console.error('跳转到智能体页面失败:', error)
router.push('/')
}
router.push('/agent')
}
onMounted(() => {

View File

@ -0,0 +1,136 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageHeader from '@/components/shared/PageHeader.vue'
import AgentManagePanel from '@/components/model-management/AgentManagePanel.vue'
import ModelProviderManagePanel from '@/components/model-management/ModelProviderManagePanel.vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const activeTab = ref('agents')
const agentPanelRef = ref(null)
const providerPanelRef = ref(null)
const modelManageTabs = computed(() => {
const tabs = [{ key: 'agents', label: '智能体' }]
if (userStore.isAdmin) tabs.push({ key: 'providers', label: '模型供应商' })
return tabs
})
const activePanel = computed(() =>
activeTab.value === 'providers' ? providerPanelRef.value : agentPanelRef.value
)
const activeLoading = computed(() => activePanel.value?.loading || false)
const activeStats = computed(() => activePanel.value?.stats || {})
const normalizeTab = (tab) => {
if (tab === 'providers' && userStore.isAdmin) return 'providers'
return 'agents'
}
watch(
() => [route.query.tab, userStore.isAdmin],
([tab]) => {
const nextTab = normalizeTab(tab)
if (activeTab.value !== nextTab) activeTab.value = nextTab
},
{ immediate: true }
)
watch(activeTab, (tab) => {
const nextTab = normalizeTab(tab)
if (nextTab !== tab) {
activeTab.value = nextTab
return
}
if (route.query.tab === nextTab) return
router.replace({ query: { ...route.query, tab: nextTab } })
})
</script>
<template>
<div class="model-manage-view">
<PageHeader
v-model:active-key="activeTab"
title="模型管理"
:tabs="modelManageTabs"
:loading="activeLoading"
:show-border="true"
aria-label="模型管理视图切换"
>
<template #info>
<div v-if="activeTab === 'agents'" class="summary-strip">
<span>{{ activeStats.total || 0 }} 个智能体</span>
<span>{{ activeStats.global || 0 }} 个全局</span>
<span v-if="activeStats.builtin">{{ activeStats.builtin }} 个内置</span>
<span>{{ activeStats.manageable || 0 }} 个可管理</span>
</div>
<div v-else class="summary-strip">
<span>{{ activeStats.total || 0 }} 个供应商</span>
<span>{{ activeStats.enabled || 0 }} 个启用</span>
<span v-if="activeStats.warning > 0" class="warning-count">
{{ activeStats.warning }} 个凭证缺失
</span>
<span>{{ activeStats.models || 0 }} 个模型</span>
</div>
</template>
</PageHeader>
<div class="model-manage-content">
<div v-show="activeTab === 'agents'" class="tab-panel">
<AgentManagePanel ref="agentPanelRef" />
</div>
<div v-if="userStore.isAdmin && activeTab === 'providers'" class="tab-panel">
<ModelProviderManagePanel ref="providerPanelRef" />
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.model-manage-view {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--gray-0);
color: var(--gray-1000);
}
.model-manage-content {
flex: 1;
min-height: 0;
overflow: hidden;
.tab-panel {
height: 100%;
min-height: 0;
overflow-y: auto;
}
}
.summary-strip {
display: flex;
gap: 8px;
span {
padding: 6px 10px;
border: 1px solid var(--gray-100);
border-radius: 7px;
background: var(--gray-10);
color: var(--gray-700);
font-size: 12px;
line-height: 18px;
}
.warning-count {
background: var(--color-warning-50);
border-color: var(--color-warning-100);
color: var(--color-warning-700);
}
}
</style>