You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1446 lines
36 KiB
1446 lines
36 KiB
<script setup lang="ts">
|
|
interface LlmProvider {
|
|
id: number
|
|
name: string
|
|
slug: string
|
|
baseUrl: string | null
|
|
parseMode: string
|
|
apiKeySet: boolean
|
|
status: string
|
|
description: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
interface LlmModel {
|
|
id: number
|
|
providerId: number
|
|
name: string
|
|
modelId: string
|
|
type: string
|
|
enabled: number
|
|
description: string | null
|
|
maxTokens: number | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
const searchQuery = ref('')
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(20)
|
|
|
|
const { data, refresh, pending } = await useHttpFetch('/api/admin/llm/providers', {
|
|
query: computed(() => ({
|
|
page: currentPage.value,
|
|
pageSize: pageSize.value,
|
|
search: searchQuery.value || undefined,
|
|
})),
|
|
watch: [currentPage, pageSize, searchQuery],
|
|
})
|
|
|
|
const providerList = computed<LlmProvider[]>(() => (data.value as any)?.list ?? [])
|
|
const totalProviders = computed(() => (data.value as any)?.total ?? 0)
|
|
const totalPages = computed(() => (data.value as any)?.totalPages ?? 1)
|
|
|
|
const showProviderDrawer = ref(false)
|
|
const editingProvider = ref<LlmProvider | null>(null)
|
|
const providerForm = ref({
|
|
name: '',
|
|
slug: '',
|
|
baseUrl: '',
|
|
parseMode: 'openai' as string,
|
|
apiKey: '',
|
|
status: 'active' as string,
|
|
description: '',
|
|
})
|
|
const providerLoading = ref(false)
|
|
const providerError = ref('')
|
|
|
|
const showModelDrawer = ref(false)
|
|
const editingModel = ref<LlmModel | null>(null)
|
|
const currentProviderId = ref<number | null>(null)
|
|
const modelForm = ref({
|
|
name: '',
|
|
modelId: '',
|
|
type: 'text' as string,
|
|
enabled: 1,
|
|
description: '',
|
|
maxTokens: null as number | null,
|
|
})
|
|
const modelLoading = ref(false)
|
|
const modelError = ref('')
|
|
const availableModels = ref<{ id: string; name: string }[]>([])
|
|
const availableModelsLoading = ref(false)
|
|
const modelIdInputMode = ref<'select' | 'input'>('select')
|
|
|
|
watch(() => modelForm.value.modelId, (newVal) => {
|
|
if (!editingModel.value && modelIdInputMode.value === 'select' && newVal) {
|
|
const selected = availableModels.value.find(m => m.id === newVal)
|
|
if (selected && !modelForm.value.name) {
|
|
modelForm.value.name = selected.name
|
|
}
|
|
}
|
|
})
|
|
|
|
const showDeleteDialog = ref(false)
|
|
const deleteTarget = ref<{ type: 'provider' | 'model'; id: number; name: string } | null>(null)
|
|
const deleteLoading = ref(false)
|
|
|
|
const expandedProviderId = ref<number | null>(null)
|
|
const providerModels = ref<Map<number, LlmModel[]>>(new Map())
|
|
const modelsLoading = ref(false)
|
|
|
|
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
|
if (import.meta.client) {
|
|
const event = new CustomEvent('toast', { detail: { message, type } })
|
|
window.dispatchEvent(event)
|
|
}
|
|
}
|
|
|
|
function openCreateProvider() {
|
|
editingProvider.value = null
|
|
providerForm.value = { name: '', slug: '', baseUrl: '', parseMode: 'openai', apiKey: '', status: 'active', description: '' }
|
|
providerError.value = ''
|
|
showProviderDrawer.value = true
|
|
}
|
|
|
|
function openEditProvider(p: LlmProvider) {
|
|
editingProvider.value = p
|
|
providerForm.value = {
|
|
name: p.name,
|
|
slug: p.slug,
|
|
baseUrl: p.baseUrl || '',
|
|
parseMode: p.parseMode,
|
|
apiKey: '',
|
|
status: p.status,
|
|
description: p.description || '',
|
|
}
|
|
providerError.value = ''
|
|
showProviderDrawer.value = true
|
|
}
|
|
|
|
function closeProviderDrawer() {
|
|
showProviderDrawer.value = false
|
|
editingProvider.value = null
|
|
}
|
|
|
|
async function handleProviderSave() {
|
|
providerError.value = ''
|
|
if (!providerForm.value.name || !providerForm.value.slug) {
|
|
providerError.value = '供应商名称和标识不能为空'
|
|
return
|
|
}
|
|
providerLoading.value = true
|
|
try {
|
|
if (editingProvider.value) {
|
|
await $fetch(`/api/admin/llm/providers/${editingProvider.value.id}` as string, {
|
|
method: 'PUT' as const,
|
|
body: {
|
|
...providerForm.value,
|
|
apiKey: providerForm.value.apiKey || '__unchanged__',
|
|
},
|
|
})
|
|
showToast('供应商更新成功')
|
|
} else {
|
|
await $fetch('/api/admin/llm/providers', {
|
|
method: 'POST',
|
|
body: providerForm.value,
|
|
})
|
|
showToast('供应商创建成功')
|
|
}
|
|
closeProviderDrawer()
|
|
await refresh()
|
|
} catch (err: any) {
|
|
providerError.value = err?.data?.statusMessage || '操作失败'
|
|
} finally {
|
|
providerLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleProviderStatus(p: LlmProvider) {
|
|
const newStatus = p.status === 'active' ? 'disabled' : 'active'
|
|
try {
|
|
await $fetch(`/api/admin/llm/providers/${p.id}` as string, {
|
|
method: 'PUT' as const,
|
|
body: { status: newStatus },
|
|
})
|
|
showToast(`供应商已${newStatus === 'active' ? '启用' : '禁用'}`)
|
|
await refresh()
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '操作失败', 'error')
|
|
}
|
|
}
|
|
|
|
async function loadModels(providerId: number) {
|
|
if (expandedProviderId.value === providerId) {
|
|
expandedProviderId.value = null
|
|
return
|
|
}
|
|
expandedProviderId.value = providerId
|
|
modelsLoading.value = true
|
|
try {
|
|
const res = await $fetch(`/api/admin/llm/models/${providerId}`)
|
|
providerModels.value.set(providerId, (res as any)?.data ?? [])
|
|
} catch {
|
|
providerModels.value.set(providerId, [])
|
|
} finally {
|
|
modelsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchAvailableModels(providerId: number) {
|
|
availableModelsLoading.value = true
|
|
availableModels.value = []
|
|
try {
|
|
const res = await $fetch(`/api/admin/llm/providers/${providerId}/models`)
|
|
const models = (res as any)?.data ?? []
|
|
if (models.length > 0) {
|
|
availableModels.value = models
|
|
modelIdInputMode.value = 'select'
|
|
} else {
|
|
modelIdInputMode.value = 'input'
|
|
}
|
|
} catch {
|
|
modelIdInputMode.value = 'input'
|
|
} finally {
|
|
availableModelsLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openCreateModel(providerId: number) {
|
|
currentProviderId.value = providerId
|
|
editingModel.value = null
|
|
modelForm.value = { name: '', modelId: '', type: 'text', enabled: 1, description: '', maxTokens: null }
|
|
modelError.value = ''
|
|
showModelDrawer.value = true
|
|
fetchAvailableModels(providerId)
|
|
}
|
|
|
|
function openEditModel(m: LlmModel) {
|
|
currentProviderId.value = m.providerId
|
|
editingModel.value = m
|
|
modelForm.value = {
|
|
name: m.name,
|
|
modelId: m.modelId,
|
|
type: m.type,
|
|
enabled: m.enabled,
|
|
description: m.description || '',
|
|
maxTokens: m.maxTokens,
|
|
}
|
|
modelError.value = ''
|
|
showModelDrawer.value = true
|
|
}
|
|
|
|
function closeModelDrawer() {
|
|
showModelDrawer.value = false
|
|
editingModel.value = null
|
|
currentProviderId.value = null
|
|
}
|
|
|
|
async function handleModelSave() {
|
|
modelError.value = ''
|
|
if (!modelForm.value.name || !modelForm.value.modelId) {
|
|
modelError.value = '模型名称和模型ID不能为空'
|
|
return
|
|
}
|
|
modelLoading.value = true
|
|
try {
|
|
if (editingModel.value) {
|
|
await $fetch(`/api/admin/llm/models/detail/${editingModel.value.id}` as string, {
|
|
method: 'PUT' as const,
|
|
body: modelForm.value,
|
|
})
|
|
showToast('模型更新成功')
|
|
} else {
|
|
await $fetch(`/api/admin/llm/models/${currentProviderId.value}` as string, {
|
|
method: 'POST' as const,
|
|
body: modelForm.value,
|
|
})
|
|
showToast('模型创建成功')
|
|
}
|
|
closeModelDrawer()
|
|
if (expandedProviderId.value) {
|
|
await loadModels(expandedProviderId.value)
|
|
}
|
|
} catch (err: any) {
|
|
modelError.value = err?.data?.statusMessage || '操作失败'
|
|
} finally {
|
|
modelLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleModelEnabled(m: LlmModel) {
|
|
const newEnabled = m.enabled ? 0 : 1
|
|
try {
|
|
await $fetch(`/api/admin/llm/models/detail/${m.id}` as string, {
|
|
method: 'PUT' as const,
|
|
body: { enabled: newEnabled },
|
|
})
|
|
showToast(`模型已${newEnabled ? '启用' : '禁用'}`)
|
|
if (expandedProviderId.value) {
|
|
await loadModels(expandedProviderId.value)
|
|
}
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '操作失败', 'error')
|
|
}
|
|
}
|
|
|
|
function confirmDelete(type: 'provider' | 'model', id: number, name: string) {
|
|
deleteTarget.value = { type, id, name }
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteTarget.value) return
|
|
deleteLoading.value = true
|
|
try {
|
|
if (deleteTarget.value.type === 'provider') {
|
|
await $fetch(`/api/admin/llm/providers/${deleteTarget.value.id}` as string, { method: 'DELETE' as const })
|
|
if (expandedProviderId.value === deleteTarget.value.id) {
|
|
expandedProviderId.value = null
|
|
}
|
|
await refresh()
|
|
} else {
|
|
await $fetch(`/api/admin/llm/models/detail/${deleteTarget.value.id}` as string, { method: 'DELETE' as const })
|
|
if (expandedProviderId.value) {
|
|
await loadModels(expandedProviderId.value)
|
|
}
|
|
}
|
|
showToast('删除成功')
|
|
showDeleteDialog.value = false
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '删除失败', 'error')
|
|
} finally {
|
|
deleteLoading.value = false
|
|
}
|
|
}
|
|
|
|
function goToPage(page: number) {
|
|
if (page < 1 || page > totalPages.value) return
|
|
currentPage.value = page
|
|
}
|
|
|
|
function formatDate(date: string) {
|
|
if (!date) return '-'
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date(date))
|
|
}
|
|
|
|
function parseModeLabel(mode: string) {
|
|
const map: Record<string, string> = { openai: 'OpenAI', anthropic: 'Anthropic' }
|
|
return map[mode] ?? mode
|
|
}
|
|
|
|
function modelTypeLabel(type: string) {
|
|
const map: Record<string, string> = { text: '文本', vision: '视觉', multimodal: '多模态' }
|
|
return map[type] ?? type
|
|
}
|
|
|
|
function modelTypeClass(type: string) {
|
|
const map: Record<string, string> = { text: 'type-text', vision: 'type-vision', multimodal: 'type-multimodal' }
|
|
return map[type] ?? 'type-text'
|
|
}
|
|
|
|
function statusClass(status: string) {
|
|
return status === 'active' ? 'status-active' : 'status-disabled'
|
|
}
|
|
|
|
function statusLabel(status: string) {
|
|
return status === 'active' ? '启用' : '禁用'
|
|
}
|
|
|
|
const currentModels = computed(() => {
|
|
if (!expandedProviderId.value) return []
|
|
return providerModels.value.get(expandedProviderId.value) ?? []
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="llm-config-page">
|
|
<header class="page-header">
|
|
<div class="header-left">
|
|
<h1 class="page-title">大模型配置</h1>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ totalProviders }}</div>
|
|
<div class="stat-label">供应商总数</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="filter-bar">
|
|
<div class="filter-left">
|
|
<div class="search-box">
|
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
|
|
</svg>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="搜索供应商名称"
|
|
class="search-input"
|
|
@keyup.enter="currentPage = 1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button class="btn-primary" @click="openCreateProvider">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />
|
|
<line x1="5" y1="12" x2="19" y2="12" stroke-linecap="round" />
|
|
</svg>
|
|
新增供应商
|
|
</button>
|
|
</div>
|
|
|
|
<div class="provider-list">
|
|
<div v-if="pending" class="empty-state">加载中...</div>
|
|
<div v-else-if="providerList.length === 0" class="empty-state">暂无供应商,点击上方按钮添加</div>
|
|
|
|
<div
|
|
v-for="p in providerList"
|
|
:key="p.id"
|
|
class="provider-card"
|
|
:class="{ expanded: expandedProviderId === p.id }"
|
|
>
|
|
<div class="provider-header" @click="loadModels(p.id)">
|
|
<div class="provider-info">
|
|
<div class="provider-name-row">
|
|
<span class="provider-name">{{ p.name }}</span>
|
|
<span :class="['status-badge', statusClass(p.status)]">{{ statusLabel(p.status) }}</span>
|
|
<span class="parse-mode-badge">{{ parseModeLabel(p.parseMode) }}</span>
|
|
</div>
|
|
<div class="provider-meta">
|
|
<span class="meta-item">
|
|
<Icon name="lucide:hash" />
|
|
{{ p.slug }}
|
|
</span>
|
|
<span v-if="p.baseUrl" class="meta-item">
|
|
<Icon name="lucide:link" />
|
|
{{ p.baseUrl }}
|
|
</span>
|
|
<span class="meta-item">
|
|
<Icon name="lucide:key" />
|
|
{{ p.apiKeySet ? '已配置' : '未配置' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="provider-actions" @click.stop>
|
|
<button class="action-btn" @click="toggleProviderStatus(p)">
|
|
{{ p.status === 'active' ? '禁用' : '启用' }}
|
|
</button>
|
|
<button class="action-btn" @click="openEditProvider(p)">编辑</button>
|
|
<button class="action-btn action-btn-danger" @click="confirmDelete('provider', p.id, p.name)">删除</button>
|
|
<button class="expand-btn" :class="{ rotated: expandedProviderId === p.id }" @click="loadModels(p.id)">
|
|
<Icon name="lucide:chevron-down" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Transition name="expand">
|
|
<div v-if="expandedProviderId === p.id" class="models-section">
|
|
<div class="models-header">
|
|
<span class="models-title">模型列表</span>
|
|
<button class="btn-sm-primary" @click="openCreateModel(p.id)">
|
|
<Icon name="lucide:plus" />
|
|
添加模型
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="modelsLoading" class="empty-state">加载中...</div>
|
|
<div v-else-if="currentModels.length === 0" class="empty-state-sm">暂无模型</div>
|
|
|
|
<div v-else class="models-grid">
|
|
<div v-for="m in currentModels" :key="m.id" class="model-card">
|
|
<div class="model-header">
|
|
<span class="model-name">{{ m.name }}</span>
|
|
<span :class="['model-type-badge', modelTypeClass(m.type)]">{{ modelTypeLabel(m.type) }}</span>
|
|
</div>
|
|
<div class="model-meta">
|
|
<span class="meta-item">
|
|
<Icon name="lucide:cpu" />
|
|
{{ m.modelId }}
|
|
</span>
|
|
<span v-if="m.maxTokens" class="meta-item">
|
|
<Icon name="lucide:arrow-up-right" />
|
|
{{ m.maxTokens.toLocaleString() }} tokens
|
|
</span>
|
|
</div>
|
|
<div class="model-actions">
|
|
<button
|
|
class="action-btn-sm"
|
|
:class="m.enabled ? 'action-btn-disable' : 'action-btn-enable'"
|
|
@click="toggleModelEnabled(m)"
|
|
>
|
|
{{ m.enabled ? '禁用' : '启用' }}
|
|
</button>
|
|
<button class="action-btn-sm" @click="openEditModel(m)">编辑</button>
|
|
<button class="action-btn-sm action-btn-danger" @click="confirmDelete('model', m.id, m.name)">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="totalPages > 1" class="pagination">
|
|
<div class="page-info">共 {{ totalProviders }} 条,第 {{ currentPage }}/{{ totalPages }} 页</div>
|
|
<div class="page-buttons">
|
|
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
|
|
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<Transition name="slide-right">
|
|
<div v-if="showProviderDrawer" class="drawer-overlay" @click.self="closeProviderDrawer">
|
|
<div class="drawer-panel">
|
|
<div class="drawer-header">
|
|
<h3 class="drawer-title">{{ editingProvider ? '编辑供应商' : '新增供应商' }}</h3>
|
|
<button class="drawer-close" @click="closeProviderDrawer">
|
|
<Icon name="lucide:x" />
|
|
</button>
|
|
</div>
|
|
<form class="drawer-body" @submit.prevent="handleProviderSave">
|
|
<div v-if="providerError" class="form-error">{{ providerError }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">供应商名称 <span class="required">*</span></label>
|
|
<input v-model="providerForm.name" type="text" class="form-input" placeholder="如:OpenAI" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">标识 (slug) <span class="required">*</span></label>
|
|
<input v-model="providerForm.slug" type="text" class="form-input" placeholder="如:openai,仅英文小写" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Base URL</label>
|
|
<input v-model="providerForm.baseUrl" type="text" class="form-input" placeholder="如:https://api.openai.com/v1" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">解析方式</label>
|
|
<select v-model="providerForm.parseMode" class="form-select">
|
|
<option value="openai">OpenAI</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">API Key {{ editingProvider ? '(留空则不修改)' : '' }}</label>
|
|
<input v-model="providerForm.apiKey" type="password" class="form-input" placeholder="sk-..." />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">状态</label>
|
|
<select v-model="providerForm.status" class="form-select">
|
|
<option value="active">启用</option>
|
|
<option value="disabled">禁用</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">描述</label>
|
|
<textarea v-model="providerForm.description" class="form-textarea" rows="3" placeholder="供应商描述(选填)"></textarea>
|
|
</div>
|
|
|
|
<div class="drawer-footer">
|
|
<button type="button" class="btn-secondary" @click="closeProviderDrawer">取消</button>
|
|
<button type="submit" class="btn-primary" :disabled="providerLoading">
|
|
{{ providerLoading ? '保存中...' : '保存' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<Teleport to="body">
|
|
<Transition name="slide-right">
|
|
<div v-if="showModelDrawer" class="drawer-overlay" @click.self="closeModelDrawer">
|
|
<div class="drawer-panel">
|
|
<div class="drawer-header">
|
|
<h3 class="drawer-title">{{ editingModel ? '编辑模型' : '新增模型' }}</h3>
|
|
<button class="drawer-close" @click="closeModelDrawer">
|
|
<Icon name="lucide:x" />
|
|
</button>
|
|
</div>
|
|
<form class="drawer-body" @submit.prevent="handleModelSave">
|
|
<div v-if="modelError" class="form-error">{{ modelError }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">模型名称 <span class="required">*</span></label>
|
|
<input v-model="modelForm.name" type="text" class="form-input" placeholder="如:GPT-4o" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">模型 ID <span class="required">*</span></label>
|
|
<div v-if="editingModel" class="form-input-static">{{ modelForm.modelId }}</div>
|
|
<template v-else>
|
|
<div v-if="availableModelsLoading" class="form-hint">正在获取可用模型列表...</div>
|
|
<div v-else-if="modelIdInputMode === 'select' && availableModels.length > 0" class="model-id-combo">
|
|
<select v-model="modelForm.modelId" class="form-select">
|
|
<option value="" disabled>请选择模型</option>
|
|
<option v-for="m in availableModels" :key="m.id" :value="m.id">{{ m.name }}</option>
|
|
</select>
|
|
<button type="button" class="toggle-input-btn" @click="modelIdInputMode = 'input'">手动输入</button>
|
|
</div>
|
|
<div v-else class="model-id-combo">
|
|
<input v-model="modelForm.modelId" type="text" class="form-input" placeholder="如:gpt-4o" />
|
|
<button v-if="availableModels.length > 0" type="button" class="toggle-input-btn" @click="modelIdInputMode = 'select'">从列表选择</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">模型类型</label>
|
|
<select v-model="modelForm.type" class="form-select">
|
|
<option value="text">文本</option>
|
|
<option value="vision">视觉</option>
|
|
<option value="multimodal">多模态</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">最大 Tokens</label>
|
|
<input v-model.number="modelForm.maxTokens" type="number" class="form-input" placeholder="如:128000" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">描述</label>
|
|
<textarea v-model="modelForm.description" class="form-textarea" rows="3" placeholder="模型描述(选填)"></textarea>
|
|
</div>
|
|
|
|
<div class="drawer-footer">
|
|
<button type="button" class="btn-secondary" @click="closeModelDrawer">取消</button>
|
|
<button type="submit" class="btn-primary" :disabled="modelLoading">
|
|
{{ modelLoading ? '保存中...' : '保存' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="showDeleteDialog" class="modal-overlay" @click.self="showDeleteDialog = false">
|
|
<div class="confirm-dialog">
|
|
<div class="confirm-header">
|
|
<h3 class="confirm-title">确认删除</h3>
|
|
</div>
|
|
<div class="confirm-body">
|
|
<p>确定删除「{{ deleteTarget?.name }}」?{{ deleteTarget?.type === 'provider' ? '该供应商下的所有模型也将被删除。' : '' }}此操作不可撤销。</p>
|
|
</div>
|
|
<div class="confirm-footer">
|
|
<button class="btn-secondary" @click="showDeleteDialog = false">取消</button>
|
|
<button class="btn-error" :disabled="deleteLoading" @click="handleDelete">
|
|
{{ deleteLoading ? '删除中...' : '确认删除' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.llm-config-page {
|
|
padding: 40px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.header-left {
|
|
flex: 1;
|
|
}
|
|
|
|
.page-title {
|
|
font-family: var(--font-display);
|
|
font-size: 32px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
letter-spacing: -0.3px;
|
|
margin: 0 0 20px 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 16px;
|
|
max-width: 600px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-display);
|
|
font-size: 28px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
line-height: 1;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.filter-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.filter-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 12px;
|
|
width: 16px;
|
|
height: 16px;
|
|
color: var(--color-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 8px 12px 8px 36px;
|
|
background: var(--color-surface-card);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
color: var(--color-ink);
|
|
width: 240px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.search-input::placeholder {
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
|
|
}
|
|
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 16px;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--color-primary-active);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.provider-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 20px;
|
|
color: var(--color-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.empty-state-sm {
|
|
text-align: center;
|
|
padding: 24px 20px;
|
|
color: var(--color-muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.provider-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
transition: box-shadow 0.15s ease;
|
|
}
|
|
|
|
.provider-card:hover {
|
|
box-shadow: 0 1px 3px rgba(20, 20, 19, 0.08);
|
|
}
|
|
|
|
.provider-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 20px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.provider-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.provider-name-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.provider-name {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 9999px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-active {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.status-disabled {
|
|
background: var(--color-hairline);
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.parse-mode-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 9999px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
background: rgba(204, 120, 92, 0.15);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.provider-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.meta-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.meta-item :deep(svg) {
|
|
width: 13px;
|
|
height: 13px;
|
|
}
|
|
|
|
.provider-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--color-hairline);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.action-btn-danger:hover {
|
|
background: rgba(198, 69, 69, 0.1);
|
|
color: var(--color-error);
|
|
}
|
|
|
|
.expand-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--color-muted);
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.expand-btn :deep(svg) {
|
|
width: 16px;
|
|
height: 16px;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.expand-btn.rotated :deep(svg) {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.expand-btn:hover {
|
|
background: var(--color-hairline);
|
|
}
|
|
|
|
.models-section {
|
|
padding: 0 20px 16px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.models-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.models-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.btn-sm-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 12px;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
}
|
|
|
|
.btn-sm-primary:hover {
|
|
background: var(--color-primary-active);
|
|
}
|
|
|
|
.btn-sm-primary :deep(svg) {
|
|
width: 13px;
|
|
height: 13px;
|
|
}
|
|
|
|
.models-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.model-card {
|
|
background: var(--color-canvas);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.model-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.model-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.model-type-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 9999px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.type-text {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.type-vision {
|
|
background: rgba(232, 165, 90, 0.15);
|
|
color: var(--color-accent-amber);
|
|
}
|
|
|
|
.type-multimodal {
|
|
background: rgba(204, 120, 92, 0.15);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.model-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.model-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.action-btn-sm {
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.action-btn-sm:hover {
|
|
background: var(--color-hairline);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.action-btn-sm.action-btn-danger:hover {
|
|
background: rgba(198, 69, 69, 0.1);
|
|
color: var(--color-error);
|
|
}
|
|
|
|
.action-btn-enable {
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.action-btn-enable:hover {
|
|
background: rgba(93, 184, 166, 0.1);
|
|
}
|
|
|
|
.action-btn-disable {
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 20px 0;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.page-info {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.page-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.page-btn {
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.page-btn:hover:not(:disabled) {
|
|
background: var(--color-hairline);
|
|
}
|
|
|
|
.page-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.drawer-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
z-index: 200;
|
|
}
|
|
|
|
.drawer-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 480px;
|
|
max-width: 100%;
|
|
background: var(--color-canvas);
|
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.drawer-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.drawer-title {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.drawer-close {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--color-muted);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.drawer-close:hover {
|
|
background: var(--color-surface-soft);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.drawer-close :deep(svg) {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.drawer-body {
|
|
flex: 1;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.drawer-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
margin-top: auto;
|
|
}
|
|
|
|
.form-error {
|
|
padding: 10px 14px;
|
|
background: rgba(198, 69, 69, 0.1);
|
|
border: 1px solid rgba(198, 69, 69, 0.2);
|
|
border-radius: 6px;
|
|
color: var(--color-error);
|
|
font-size: 13px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-body-strong);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.required {
|
|
color: var(--color-error);
|
|
}
|
|
|
|
.form-input,
|
|
.form-select,
|
|
.form-textarea {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
background: var(--color-canvas);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--color-ink);
|
|
transition: all 0.15s ease;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.form-textarea {
|
|
resize: vertical;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.form-input:focus,
|
|
.form-select:focus,
|
|
.form-textarea:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
|
|
}
|
|
|
|
.form-input::placeholder,
|
|
.form-textarea::placeholder {
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.form-input-static {
|
|
padding: 10px 14px;
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.form-hint {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.model-id-combo {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.model-id-combo .form-select,
|
|
.model-id-combo .form-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.toggle-input-btn {
|
|
flex-shrink: 0;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--color-primary);
|
|
background: rgba(204, 120, 92, 0.1);
|
|
border: 1px solid rgba(204, 120, 92, 0.2);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.toggle-input-btn:hover {
|
|
background: rgba(204, 120, 92, 0.2);
|
|
}
|
|
|
|
.btn-secondary {
|
|
padding: 10px 18px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-body-strong);
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--color-hairline);
|
|
}
|
|
|
|
.btn-error {
|
|
padding: 10px 18px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: white;
|
|
background: var(--color-error);
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.btn-error:hover {
|
|
background: #b33d3d;
|
|
}
|
|
|
|
.btn-error:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 300;
|
|
}
|
|
|
|
.confirm-dialog {
|
|
background: var(--color-canvas);
|
|
border-radius: 12px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.confirm-header {
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.confirm-title {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.confirm-body {
|
|
padding: 24px;
|
|
}
|
|
|
|
.confirm-body p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: var(--color-body-strong);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.confirm-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding: 16px 24px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.expand-enter-active,
|
|
.expand-leave-active {
|
|
transition: all 0.2s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.expand-enter-from,
|
|
.expand-leave-to {
|
|
opacity: 0;
|
|
max-height: 0;
|
|
}
|
|
|
|
.slide-right-enter-active,
|
|
.slide-right-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.slide-right-enter-from,
|
|
.slide-right-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.llm-config-page {
|
|
padding: 24px;
|
|
}
|
|
|
|
.filter-bar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.drawer-panel {
|
|
width: 100%;
|
|
}
|
|
|
|
.models-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.provider-header {
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.provider-actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|
|
|