321 lines
11 KiB
Vue
321 lines
11 KiB
Vue
<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>
|