渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标
- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通 (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接 - 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加 - 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标 - 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||||
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { Channel, ChannelModelMapping } from '@/types'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const channels = ref<Channel[]>([])
|
||||
@@ -15,11 +17,17 @@ const editOpen = ref(false)
|
||||
const editing = ref<Channel | null>(null)
|
||||
const saving = ref(false)
|
||||
const busyId = ref<number | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
function toggleDrawer(ch: Channel) {
|
||||
expandedId.value = expandedId.value === ch.id ? null : ch.id
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
formats: ['chat'] as string[],
|
||||
base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
@@ -40,7 +48,9 @@ async function load() {
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', formats: ['chat'], base_url: '', api_key: '',
|
||||
name: '', formats: ['chat'], base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' },
|
||||
api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
@@ -50,7 +60,13 @@ function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
|
||||
base_url: ch.base_url, api_key: '',
|
||||
base_url: ch.base_url,
|
||||
base_urls: {
|
||||
chat: ch.base_urls?.chat ?? '',
|
||||
responses: ch.base_urls?.responses ?? '',
|
||||
messages: ch.base_urls?.messages ?? '',
|
||||
},
|
||||
api_key: '',
|
||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
@@ -111,123 +127,72 @@ async function testChannel(ch: Channel) {
|
||||
}
|
||||
}
|
||||
|
||||
async function importModels(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
|
||||
toast.ok(`已导入 ${data.data.imported} 个模型`)
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// --- 渠道模型映射 ---
|
||||
const modelsOpen = ref(false)
|
||||
const mappingChan = ref<Channel | null>(null)
|
||||
const mappings = ref<ChannelModelMapping[]>([])
|
||||
const availableModels = ref<string[]>([]) // 已存在模型名,供上游模型输入联想
|
||||
const mappingForm = reactive({ upstream_model: '', custom_name: '', weight: 1 })
|
||||
const showModelSuggestions = ref(false)
|
||||
const filteredModels = computed(() => {
|
||||
const q = mappingForm.upstream_model.trim().toLowerCase()
|
||||
if (!q) return availableModels.value
|
||||
return availableModels.value.filter((m) => m.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function pickSuggestion(m: string) {
|
||||
mappingForm.upstream_model = m
|
||||
showModelSuggestions.value = false
|
||||
}
|
||||
|
||||
async function openModels(ch: Channel) {
|
||||
mappingChan.value = ch
|
||||
mappingForm.upstream_model = ''
|
||||
mappingForm.custom_name = ''
|
||||
mappingForm.weight = 1
|
||||
modelsOpen.value = true
|
||||
await Promise.all([loadMappings(), loadAvailableModels()])
|
||||
}
|
||||
|
||||
async function loadMappings() {
|
||||
if (!mappingChan.value) return
|
||||
try {
|
||||
const { data } = await http.get(`/admin/channels/${mappingChan.value.id}/models`)
|
||||
mappings.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableModels() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/models')
|
||||
availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
async function addMapping() {
|
||||
if (!mappingChan.value || !mappingForm.upstream_model) return
|
||||
try {
|
||||
await http.post(`/admin/channels/${mappingChan.value.id}/models`, {
|
||||
upstream_model: mappingForm.upstream_model,
|
||||
custom_name: mappingForm.custom_name,
|
||||
weight: Number(mappingForm.weight) || 1,
|
||||
})
|
||||
toast.ok('已添加')
|
||||
mappingForm.upstream_model = ''
|
||||
mappingForm.custom_name = ''
|
||||
showModelSuggestions.value = false
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMapping(b: ChannelModelMapping) {
|
||||
if (!mappingChan.value) return
|
||||
try {
|
||||
await http.patch(`/admin/channels/${mappingChan.value.id}/models/${b.id}`, {
|
||||
upstream_model: b.upstream_model,
|
||||
})
|
||||
toast.ok('已更新')
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapping(b: ChannelModelMapping) {
|
||||
if (!mappingChan.value) return
|
||||
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
|
||||
try {
|
||||
await http.delete(`/admin/channels/${mappingChan.value.id}/models/${b.id}`)
|
||||
toast.ok('已解除')
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">渠道</h1>
|
||||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||||
</div>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- 移动端:卡片列表 -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div v-for="ch in channels" :key="ch.id" class="card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1">
|
||||
<code
|
||||
v-for="f in ch.formats || []"
|
||||
:key="f"
|
||||
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||||
>{{ protocolShort(f) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||
{{ ch.health_status }}
|
||||
</Badge>
|
||||
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<PhPulse :size="13" />
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
|
||||
<PhStack :size="13" />
|
||||
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||
<PhNotePencil :size="13" />
|
||||
编辑
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||
<PhTrash :size="13" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
|
||||
<ChannelModelsDrawer :channel="ch" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||||
还没有渠道,点击「添加渠道」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格 -->
|
||||
<div class="card hidden md:block">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<thead>
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
@@ -240,8 +205,14 @@ onMounted(load)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
|
||||
<template v-for="ch in channels" :key="ch.id">
|
||||
<tr class="table-row">
|
||||
<td class="px-4 py-2.5">
|
||||
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||||
<span class="truncate">{{ ch.name }}</span>
|
||||
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<code
|
||||
@@ -261,16 +232,27 @@ onMounted(load)
|
||||
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<PhPulse :size="13" />
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="openModels(ch)">模型映射</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="remove(ch)">删除</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||
<PhNotePencil :size="13" />
|
||||
编辑
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||
<PhTrash :size="13" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === ch.id" class="bg-surface/40">
|
||||
<td colspan="7" class="px-4 py-3">
|
||||
<ChannelModelsDrawer :channel="ch" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="channels.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
|
||||
</tr>
|
||||
@@ -308,12 +290,19 @@ onMounted(load)
|
||||
placeholder="https://api.openai.com/v1"
|
||||
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
|
||||
/>
|
||||
<div class="space-y-3 rounded-md border border-edge p-3">
|
||||
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
|
||||
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" />
|
||||
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" />
|
||||
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" />
|
||||
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
|
||||
</div>
|
||||
<Input
|
||||
v-model="form.api_key"
|
||||
label="上游 API Key"
|
||||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input v-model="form.weight" label="权重" type="number" />
|
||||
<Input v-model="form.priority" label="优先级" type="number" />
|
||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||
@@ -325,76 +314,5 @@ onMounted(load)
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 模型名称映射 -->
|
||||
<Modal :open="modelsOpen" :title="`支持的模型 · ${mappingChan?.name}`" @close="modelsOpen = false">
|
||||
<div class="mb-3 text-xs text-muted">
|
||||
客户端调用「客户端名称」,网关转发为「上游模型」。无 /models 接口的渠道可手工添加。
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-3 py-2 font-medium">客户端名称</th>
|
||||
<th scope="col" class="px-3 py-2 font-medium">上游模型</th>
|
||||
<th scope="col" class="px-3 py-2 font-medium">权重</th>
|
||||
<th scope="col" class="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in mappings" :key="b.id" class="border-b border-edge last:border-0">
|
||||
<td class="px-3 py-2 font-mono text-xs text-ink">{{ b.model_name }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<input
|
||||
v-model="b.upstream_model"
|
||||
class="h-8 w-full min-w-36 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs text-ink outline-none focus:border-accent"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 mono-num text-xs text-muted">{{ b.weight }}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="saveMapping(b)">保存</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="deleteMapping(b)">解除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mappings.length === 0">
|
||||
<td colspan="4" class="px-3 py-6 text-center text-xs text-muted">尚未添加模型,可在下方手工填写</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-3 flex items-center gap-2 border-t border-edge pt-3">
|
||||
<input
|
||||
v-model="mappingForm.custom_name"
|
||||
placeholder="自定义名称(可选)"
|
||||
class="h-9 w-36 shrink-0 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@keyup.enter="addMapping"
|
||||
/>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
v-model="mappingForm.upstream_model"
|
||||
placeholder="上游模型名"
|
||||
class="h-9 w-full rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@focus="showModelSuggestions = true"
|
||||
@input="showModelSuggestions = true"
|
||||
@keydown.enter="addMapping"
|
||||
@keydown.esc="showModelSuggestions = false"
|
||||
/>
|
||||
<div
|
||||
v-if="showModelSuggestions && filteredModels.length"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-40 overflow-y-auto rounded-md border border-edge bg-surface py-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="m in filteredModels"
|
||||
:key="m"
|
||||
class="block w-full truncate px-2.5 py-1.5 text-left font-mono text-xs text-ink hover:bg-surface2"
|
||||
@mousedown.prevent="pickSuggestion(m)"
|
||||
>
|
||||
{{ m }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" class="shrink-0" @click="addMapping">添加</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user