渠道: 分协议 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>