Files
Atlas/web/src/views/AdminProvidersView.vue
T
renjue f920f465ed
CI / docker (push) Successful in 2m28s
实现 Atlas AI 平台一期能力并完成品牌化改造。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 23:22:19 +08:00

321 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import {
adaptProviderForm,
defaultServiceType,
protocolForServiceType,
protocolLabel,
protocolOptions,
serviceTypeLabel,
serviceTypeOptionsForProtocol,
} from '@/constants/providerCompat'
interface Provider {
id?: number
name: string
protocol: string
base_url: string
api_key: string
service_type: string
is_default: boolean
enabled: boolean
}
interface ModelEntry {
id: string
name: string
}
interface ModelsBreakdown {
upstream: ModelEntry[]
manual: ModelEntry[]
effective: ModelEntry[]
}
const loading = ref(true)
const saving = ref(false)
const syncing = ref(false)
const items = ref<Provider[]>([])
const dialogVisible = ref(false)
const modelsDialogVisible = ref(false)
const activeProviderId = ref<number | null>(null)
const models = ref<ModelsBreakdown>({ upstream: [], manual: [], effective: [] })
const newModel = reactive({ model_id: '', name: '' })
const form = reactive<Provider>({
name: '',
protocol: 'openai',
base_url: '',
api_key: '',
service_type: 'chat',
is_default: false,
enabled: true,
})
const filteredProtocols = computed(() => protocolOptions)
const filteredServiceTypes = computed(() => serviceTypeOptionsForProtocol(form.protocol))
const bindingHint = computed(() => {
if (form.protocol === 'openai') return 'OpenAI 协议用于对话(Chat Completions'
return 'Replicate 协议用于生图(Predictions),文生图/图生图由模型 schema 自动识别'
})
let syncingForm = false
watch(
() => form.protocol,
(protocol) => {
if (syncingForm) return
syncingForm = true
const allowed = serviceTypeOptionsForProtocol(protocol).map((o) => o.value)
if (!(allowed as readonly string[]).includes(form.service_type)) {
form.service_type = defaultServiceType(protocol)
}
syncingForm = false
},
)
watch(
() => form.service_type,
(serviceType) => {
if (syncingForm) return
syncingForm = true
const expected = protocolForServiceType(serviceType)
if (form.protocol !== expected) {
form.protocol = expected
}
syncingForm = false
},
)
onMounted(load)
async function load() {
loading.value = true
try {
const res = await api.get<{ items: Provider[] }>('/admin/providers')
items.value = res.data.items
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, {
id: undefined,
name: '',
protocol: 'openai',
base_url: '',
api_key: '',
service_type: 'chat',
is_default: false,
enabled: true,
})
adaptProviderForm(form)
dialogVisible.value = true
}
function openEdit(row: Provider) {
Object.assign(form, row)
adaptProviderForm(form)
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (form.id) await api.put(`/admin/providers/${form.id}`, form)
else await api.post('/admin/providers', form)
dialogVisible.value = false
ElMessage.success('已保存')
await load()
} finally {
saving.value = false
}
}
async function remove(row: Provider) {
await ElMessageBox.confirm(`确定删除 Provider「${row.name}」?`, '确认', { type: 'warning' })
await api.delete(`/admin/providers/${row.id}`)
ElMessage.success('已删除')
await load()
}
async function openModels(row: Provider) {
if (!row.id) return
activeProviderId.value = row.id
await loadModels(row.id)
modelsDialogVisible.value = true
}
async function loadModels(providerId: number) {
const res = await api.get<ModelsBreakdown>(`/admin/providers/${providerId}/models`)
models.value = res.data
}
async function syncUpstream() {
if (!activeProviderId.value) return
syncing.value = true
try {
await api.post(`/admin/providers/${activeProviderId.value}/models/sync`)
await loadModels(activeProviderId.value)
ElMessage.success('已从上游刷新')
} catch {
ElMessage.warning('上游 models 接口不可用,请手动添加模型')
} finally {
syncing.value = false
}
}
async function addManualModel() {
if (!activeProviderId.value || !newModel.model_id.trim()) return
await api.post(`/admin/providers/${activeProviderId.value}/models`, {
model_id: newModel.model_id.trim(),
name: newModel.name.trim() || newModel.model_id.trim(),
})
newModel.model_id = ''
newModel.name = ''
await loadModels(activeProviderId.value)
ElMessage.success('已添加')
}
async function removeManual(modelId: string) {
if (!activeProviderId.value) return
await api.delete(`/admin/providers/${activeProviderId.value}/models/${encodeURIComponent(modelId)}`)
await loadModels(activeProviderId.value)
}
</script>
<template>
<div>
<PageHeader title="Provider 配置" description="配置外部 Gateway;模型优先从上游同步,亦可手动补充" />
<AppCard>
<div class="card-toolbar">
<span class="form-hint form-hint--flush"> {{ items.length }} Provider</span>
<div class="inline-actions" style="margin-left: auto">
<el-button type="primary" @click="openCreate">新建 Provider</el-button>
</div>
</div>
<el-table v-loading="loading" :data="items" size="small" empty-text="暂无 Provider,请点击右上角新建">
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="协议" width="108">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ protocolLabel(row.protocol) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="服务类型" width="108">
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ serviceTypeLabel(row.service_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Gateway URL" min-width="200">
<template #default="{ row }">
<span class="cell-mono" :title="row.base_url">{{ row.base_url }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<div class="cell-stack">
<el-tag v-if="row.is_default" size="small" type="success" effect="light">默认</el-tag>
<el-tag v-if="row.enabled" size="small" type="success" effect="plain">启用</el-tag>
<el-tag v-else size="small" type="info" effect="plain">停用</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" @click="openModels(row)">模型</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑 Provider' : '新建 Provider'" width="560px" destroy-on-close>
<el-form label-width="112px" label-position="right">
<el-form-item label="名称" required><el-input v-model="form.name" placeholder="例如:OpenAI 主线路" /></el-form-item>
<el-form-item label="协议" required>
<el-select v-model="form.protocol" style="width: 100%">
<el-option v-for="opt in filteredProtocols" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="服务类型" required>
<el-select v-model="form.service_type" style="width: 100%">
<el-option v-for="opt in filteredServiceTypes" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<p class="form-hint">{{ bindingHint }}</p>
</el-form-item>
<el-form-item label="Gateway URL" required>
<el-input v-model="form.base_url" placeholder="https://api.example.com" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="form.api_key" type="password" show-password placeholder="保存后脱敏展示" />
</el-form-item>
<el-form-item label="设为默认"><el-switch v-model="form.is_default" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="modelsDialogVisible" title="模型管理" width="680px" destroy-on-close>
<div class="card-toolbar card-toolbar--desc models-toolbar">
<el-button size="small" :loading="syncing" @click="syncUpstream">从上游同步</el-button>
<span class="form-hint form-hint--flush">Replicate / OpenAI 按协议分别解析 models 响应</span>
</div>
<h4 class="section-title">上游模型只读</h4>
<div class="table-scroll--sm">
<el-table :data="models.upstream" size="small" empty-text="暂无请点击从上游同步」">
<el-table-column prop="id" label="Model ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
</el-table>
</div>
<h4 class="section-title">手动补充</h4>
<div class="page-toolbar">
<el-input v-model="newModel.model_id" placeholder="model_id" class="toolbar-input" />
<el-input v-model="newModel.name" placeholder="显示名称" class="toolbar-input" />
<el-button type="primary" size="small" @click="addManualModel">添加</el-button>
</div>
<el-table :data="models.manual" size="small" empty-text="暂无手动模型" class="u-mt-3">
<el-table-column prop="id" label="Model ID" min-width="180" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" @click="removeManual(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="section-title">用户端可见合并后</h4>
<div class="table-scroll--sm">
<el-table :data="models.effective" size="small" empty-text="请配置上游或手动模型">
<el-table-column prop="id" label="Model ID" min-width="180" />
<el-table-column prop="name" label="名称" min-width="140" />
</el-table>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.models-toolbar {
padding: 0 0 var(--space-4);
margin-bottom: 0;
}
.cell-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
</style>