渠道: 分协议 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:
Sakurasan
2026-08-16 04:31:28 +08:00
co-authored by Claude
parent 7c4e80afac
commit db83972b6f
11 changed files with 484 additions and 246 deletions
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { PhArrowsClockwise, PhPlus, PhX } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import type { Channel, ChannelModelMapping } from '@/types'
const props = defineProps<{ channel: Channel }>()
const toast = useToastStore()
const mappings = ref<ChannelModelMapping[]>([])
const remote = ref<string[]>([])
const selected = ref<string[]>([])
const loading = ref(false)
const addForm = reactive({ custom_name: '', upstream_model: '' })
async function load() {
try {
const { data } = await http.get(`/admin/channels/${props.channel.id}/models`)
mappings.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
async function fetchRemote() {
loading.value = true
try {
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
remote.value = data.data.items
const bound = new Set(mappings.value.map((m) => m.upstream_model))
selected.value = remote.value.filter((r) => bound.has(r))
} catch (e) {
toast.err(errMsg(e))
} finally {
loading.value = false
}
}
async function addSelected() {
const bound = new Set(mappings.value.map((m) => m.upstream_model))
let added = 0
for (const name of selected.value) {
if (bound.has(name)) continue
try {
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
added++
} catch {
/* 单个失败不中断 */
}
}
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
await load()
}
async function addManual() {
if (!addForm.upstream_model.trim()) return
try {
await http.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: addForm.upstream_model.trim(),
custom_name: addForm.custom_name.trim(),
})
toast.ok('已添加')
addForm.custom_name = ''
addForm.upstream_model = ''
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function saveUpstream(b: ChannelModelMapping) {
try {
await http.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
upstream_model: b.upstream_model,
})
toast.ok('已更新')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function remove(b: ChannelModelMapping) {
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
try {
await http.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
toast.ok('已解除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="space-y-3">
<!-- 当前支持的模型 -->
<div>
<p class="mb-1.5 text-xs font-medium text-muted">当前支持的模型({{ mappings.length }})</p>
<div v-if="mappings.length" class="flex flex-wrap gap-2">
<div
v-for="b in mappings"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-1 font-mono text-[11px] text-muted"
>
<span class="text-ink">{{ b.model_name }}</span>
<span class="opacity-60">→</span>
<input
v-model="b.upstream_model"
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-accent outline-none transition focus:border-accent/50 focus:bg-surface2"
@change="saveUpstream(b)"
/>
<button class="text-muted hover:text-err" aria-label="解除" @click="remove(b)">
<PhX :size="12" />
</button>
</div>
</div>
<p v-else class="text-xs text-muted">尚未添加模型</p>
</div>
<!-- 从接口拉取 + 勾选 -->
<div class="border-t border-edge pt-3">
<div class="mb-1.5 flex items-center justify-between">
<p class="text-xs font-medium text-muted">从接口拉取模型</p>
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
<PhArrowsClockwise :size="13" />
拉取
</Button>
</div>
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in remote"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-edge2 px-2 py-1 font-mono text-[11px] text-muted transition select-none"
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'hover:border-edge'"
>
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
{{ m }}
</label>
</div>
<div v-if="remote.length" class="mt-2">
<Button size="sm" @click="addSelected">
<PhPlus :size="13" />
添加所选({{ selected.length }})
</Button>
</div>
<p v-else-if="!loading" class="text-xs text-muted">点「拉取」获取渠道接口返回的模型,勾选需要的加入</p>
</div>
<!-- 手动添加 -->
<div class="flex items-center gap-2 border-t border-edge pt-3">
<input
v-model="addForm.custom_name"
placeholder="自定义名称(可选)"
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="addManual"
/>
<input
v-model="addForm.upstream_model"
placeholder="上游模型名"
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="addManual"
/>
<Button size="sm" class="shrink-0" @click="addManual">
<PhPlus :size="13" />
添加
</Button>
</div>
</div>
</template>
+106 -188
View File
@@ -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>