feat(ui): group model providers by local/cloud with section headers

This commit is contained in:
matevip 2026-04-05 08:32:55 +08:00
parent c335baf14d
commit ef1f22356f
5 changed files with 217 additions and 113 deletions

View File

@ -193,7 +193,8 @@ public class ModelConfigService {
throw new MateClawException("模型不存在: " + providerId + "/" + modelName);
}
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("只有启用状态的模型才能设为默认");
// Auto-enable when setting as default (e.g. local Ollama models)
entity.setEnabled(true);
}
clearDefaultFlag();
entity.setIsDefault(true);

View File

@ -183,6 +183,8 @@ export default {
title: 'Model Management',
desc: 'Configure model providers, credentials, and model lists',
addProvider: 'Add Provider',
localProviders: 'Local Models',
cloudProviders: 'Cloud Models',
active: 'Active',
setActive: 'Set Active',
configured: 'Configured',

View File

@ -183,6 +183,8 @@ export default {
title: '模型管理',
desc: '配置模型 Provider、凭证和模型列表',
addProvider: '新增 Provider',
localProviders: '本地模型',
cloudProviders: '云端模型',
active: '当前激活',
setActive: '设为激活',
configured: '已配置',

View File

@ -0,0 +1,133 @@
<template>
<div>
<div class="provider-header">
<div>
<div class="provider-title-row">
<img
:src="getProviderIcon(provider.id)"
:alt="provider.name"
class="provider-icon"
@error="onIconError"
/>
<h3 class="provider-name">{{ provider.name }}</h3>
<span class="provider-badge" :class="provider.isCustom ? 'custom' : 'builtin'">
{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }}
</span>
<span v-if="isProviderActive(provider)" class="provider-badge active">
{{ t('settings.model.active') }}
</span>
</div>
<p class="provider-id">{{ provider.id }}</p>
</div>
<div class="provider-status" :class="providerStatus(provider).type">
{{ providerStatus(provider).label }}
</div>
</div>
<div class="provider-info">
<div class="info-row">
<span class="info-label">{{ t('settings.model.baseUrl') }}</span>
<span class="info-value mono" :title="provider.baseUrl || ''">
{{ provider.baseUrl || t('settings.model.notSet') }}
</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('settings.model.apiKey') }}</span>
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('settings.fields.modelName') }}</span>
<span class="info-value">
{{ t('settings.model.modelCount', { count: (provider.models?.length || 0) + (provider.extraModels?.length || 0) }) }}
</span>
</div>
</div>
<div class="card-actions">
<button class="card-btn" @click="$emit('manage-models', provider)">
{{ t('settings.model.actions.manageModels') }}
</button>
<button class="card-btn" @click="$emit('provider-settings', provider)">
{{ t('settings.model.actions.providerSettings') }}
</button>
<button
v-if="provider.supportConnectionCheck && provider.configured"
class="card-btn"
:class="{ testing: connectionTestingId === provider.id }"
:disabled="connectionTestingId === provider.id"
@click="$emit('test-connection', provider)"
>
{{ connectionTestingId === provider.id ? t('settings.model.discovery.testing') : t('settings.model.discovery.testConnection') }}
</button>
<button
v-if="provider.isCustom"
class="card-btn danger"
@click="$emit('delete-provider', provider)"
>
{{ t('common.delete') }}
</button>
</div>
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
<span v-if="connectionResults[provider.id].success">
{{ t('settings.model.discovery.connectionOk') }} · {{ t('settings.model.discovery.latency', { ms: connectionResults[provider.id].latencyMs }) }}
</span>
<span v-else>
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
defineProps<{
provider: ProviderInfo
connectionTestingId: string | null
connectionResults: Record<string, any>
isProviderActive: (provider: ProviderInfo) => boolean
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
getProviderIcon: (id: string) => string
onIconError: (e: Event) => void
}>()
defineEmits<{
'manage-models': [provider: ProviderInfo]
'provider-settings': [provider: ProviderInfo]
'test-connection': [provider: ProviderInfo]
'delete-provider': [provider: ProviderInfo]
}>()
const { t } = useI18n()
</script>
<style scoped>
.provider-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.provider-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.provider-icon { width: 28px; height: 28px; border-radius: 6px; object-fit: contain; flex-shrink: 0; }
.provider-name { margin: 0; font-size: 18px; color: var(--mc-text-primary); }
.provider-id { margin: 6px 0 0; font-size: 13px; color: var(--mc-primary); }
.provider-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; }
.provider-badge.builtin { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-badge.custom { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-badge.active { background: rgba(217, 119, 87, 0.12); color: var(--mc-primary-light); }
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-status.unavailable { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.provider-info { display: grid; gap: 10px; }
.info-row { display: flex; justify-content: space-between; gap: 12px; }
.info-label { color: var(--mc-text-secondary); font-size: 13px; }
.info-value { color: var(--mc-text-primary); font-size: 13px; text-align: right; word-break: break-all; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.card-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.card-btn { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary-bg); color: var(--mc-primary); }
.card-btn:hover { background: rgba(217, 119, 87, 0.18); }
.card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); }
.card-btn.testing { opacity: 0.6; cursor: wait; }
.connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; }
.connection-result.success { background: var(--mc-primary-bg); color: var(--mc-primary); }
.connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
</style>

View File

@ -10,83 +10,56 @@
</button>
</div>
<div class="provider-grid">
<div v-for="provider in providers" :key="provider.id" class="provider-card">
<div class="provider-header">
<div>
<div class="provider-title-row">
<img
:src="getProviderIcon(provider.id)"
:alt="provider.name"
class="provider-icon"
@error="onIconError"
/>
<h3 class="provider-name">{{ provider.name }}</h3>
<span class="provider-badge" :class="provider.isCustom ? 'custom' : 'builtin'">
{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }}
</span>
<span v-if="isProviderActive(provider)" class="provider-badge active">
{{ t('settings.model.active') }}
</span>
</div>
<p class="provider-id">{{ provider.id }}</p>
</div>
<div class="provider-status" :class="providerStatus(provider).type">
{{ providerStatus(provider).label }}
</div>
<!-- 本地模型 -->
<div v-if="localProviders.length" class="provider-group">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
{{ t('settings.model.localProviders') }}
</h3>
<div class="provider-grid">
<div v-for="provider in localProviders" :key="provider.id" class="provider-card">
<ProviderCard
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
:on-icon-error="onIconError"
@manage-models="openManageModelsModal"
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
/>
</div>
</div>
</div>
<div class="provider-info">
<div class="info-row">
<span class="info-label">{{ t('settings.model.baseUrl') }}</span>
<span class="info-value mono" :title="provider.baseUrl || ''">
{{ provider.baseUrl || t('settings.model.notSet') }}
</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('settings.model.apiKey') }}</span>
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('settings.fields.modelName') }}</span>
<span class="info-value">
{{ t('settings.model.modelCount', { count: (provider.models?.length || 0) + (provider.extraModels?.length || 0) }) }}
</span>
</div>
</div>
<div class="card-actions">
<button class="card-btn" @click="openManageModelsModal(provider)">
{{ t('settings.model.actions.manageModels') }}
</button>
<button class="card-btn" @click="openProviderConfigModal(provider)">
{{ t('settings.model.actions.providerSettings') }}
</button>
<button
v-if="provider.supportConnectionCheck && provider.configured"
class="card-btn"
:class="{ testing: connectionTestingId === provider.id }"
:disabled="connectionTestingId === provider.id"
@click="handleTestConnection(provider)"
>
{{ connectionTestingId === provider.id ? t('settings.model.discovery.testing') : t('settings.model.discovery.testConnection') }}
</button>
<button
v-if="provider.isCustom"
class="card-btn danger"
@click="onDeleteProvider(provider)"
>
{{ t('common.delete') }}
</button>
</div>
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
<span v-if="connectionResults[provider.id].success">
{{ t('settings.model.discovery.connectionOk') }} · {{ t('settings.model.discovery.latency', { ms: connectionResults[provider.id].latencyMs }) }}
</span>
<span v-else>
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
</span>
<!-- 云端模型 -->
<div v-if="cloudProviders.length" class="provider-group">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
</svg>
{{ t('settings.model.cloudProviders') }}
</h3>
<div class="provider-grid">
<div v-for="provider in cloudProviders" :key="provider.id" class="provider-card">
<ProviderCard
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
:on-icon-error="onIconError"
@manage-models="openManageModelsModal"
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
/>
</div>
</div>
</div>
@ -138,11 +111,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders'
import ProviderCard from './ProviderCard.vue'
import ProviderConfigModal from './modals/ProviderConfigModal.vue'
import ManageModelsModal from './modals/ManageModelsModal.vue'
@ -196,6 +170,9 @@ const {
onIconError,
} = useProviders()
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
onMounted(async () => {
await Promise.all([loadProviders(), loadActiveModel()])
})
@ -256,53 +233,42 @@ function showSavedTip(message: string) {
.section-title { margin: 0 0 6px; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.provider-grid {
.provider-group {
margin-bottom: 28px;
}
.group-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
}
.group-title__icon {
flex-shrink: 0;
color: var(--mc-text-secondary);
}
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
align-items: stretch;
}
.provider-card {
flex: 1 1 calc(33.333% - 16px);
min-width: 360px;
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04);
}
.provider-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.provider-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.provider-icon { width: 28px; height: 28px; border-radius: 6px; object-fit: contain; flex-shrink: 0; }
.provider-name { margin: 0; font-size: 18px; color: var(--mc-text-primary); }
.provider-id { margin: 6px 0 0; font-size: 13px; color: var(--mc-primary); }
.provider-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; }
.provider-badge.builtin { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-badge.custom { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-badge.active { background: rgba(217, 119, 87, 0.12); color: var(--mc-primary-light); }
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-status.unavailable { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.provider-info { display: grid; gap: 10px; }
.info-row { display: flex; justify-content: space-between; gap: 12px; }
.info-label { color: var(--mc-text-secondary); font-size: 13px; }
.info-value { color: var(--mc-text-primary); font-size: 13px; text-align: right; word-break: break-all; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.card-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.btn-primary, .card-btn { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.card-btn { background: var(--mc-primary-bg); color: var(--mc-primary); }
.card-btn:hover { background: rgba(217, 119, 87, 0.18); }
.card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); }
.card-btn.testing { opacity: 0.6; cursor: wait; }
.connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; }
.connection-result.success { background: var(--mc-primary-bg); color: var(--mc-primary); }
.connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); }
@media (max-width: 900px) {
.section-header { flex-direction: column; }
.provider-card { flex-basis: 100%; min-width: 0; }
}
@media (max-width: 640px) {
.provider-grid { grid-template-columns: 1fr; }
}
</style>