模型定价: 悬空模型标记 + 一键清除 + 允许渠道只读
- 无渠道绑定的模型以「悬空」琥珀色徽标 + 卡片着色标记 - 新增「清除悬空」一键删除未绑定渠道的模型(DELETE /models/unused) - 移除页面内「绑定渠道」弹窗与 chips 的解除按钮,渠道支持列表只在渠道抽屉管理 - 缺失提示带上渠道真实模型名(upstream_model) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -45,19 +45,22 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渠道提供了但目录中缺失的模型(孤儿绑定,数据不一致时出现)
|
// 渠道选中但目录中缺失的模型(孤儿绑定:渠道绑定指向已被删除的模型)
|
||||||
var orphans []struct {
|
var orphans []struct {
|
||||||
ChannelName string
|
ChannelName string
|
||||||
ModelID uint64
|
UpstreamModel string
|
||||||
|
ModelID uint64
|
||||||
}
|
}
|
||||||
h.a.DB.Raw(`SELECT c.name as channel_name, b.model_id
|
h.a.DB.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
|
||||||
FROM channel_model_bindings b
|
FROM channel_model_bindings b
|
||||||
LEFT JOIN models m ON m.id = b.model_id
|
LEFT JOIN models m ON m.id = b.model_id
|
||||||
LEFT JOIN channels c ON c.id = b.channel_id
|
LEFT JOIN channels c ON c.id = b.channel_id
|
||||||
WHERE m.id IS NULL`).Scan(&orphans)
|
WHERE m.id IS NULL`).Scan(&orphans)
|
||||||
missing := make([]gin.H, 0, len(orphans))
|
missing := make([]gin.H, 0, len(orphans))
|
||||||
for _, o := range orphans {
|
for _, o := range orphans {
|
||||||
missing = append(missing, gin.H{"channel": o.ChannelName, "model_id": o.ModelID})
|
missing = append(missing, gin.H{
|
||||||
|
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在用但未定价的模型数(渠道已提供、需定价)
|
// 在用但未定价的模型数(渠道已提供、需定价)
|
||||||
@@ -210,6 +213,29 @@ func (h *Handler) AdminDeleteModel(c *gin.Context) {
|
|||||||
resp.OK(c, gin.H{"ok": true})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminDeleteUnusedModels DELETE /api/v1/admin/models/unused — 一键清除未绑定任何渠道的模型。
|
||||||
|
// 这些通常是渠道抽屉里选中过、后来又取消绑定留下的目录条目,客户端无法调用。
|
||||||
|
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
|
||||||
|
var orphans []store.Model
|
||||||
|
if err := h.a.DB.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(orphans))
|
||||||
|
ids := make([]uint64, 0, len(orphans))
|
||||||
|
for _, m := range orphans {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
if err := h.a.DB.Delete(&store.Model{}, ids).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete models")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"deleted": names, "count": len(names)})
|
||||||
|
}
|
||||||
|
|
||||||
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
|
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
|
||||||
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
||||||
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ export interface Model {
|
|||||||
export interface ModelSummary {
|
export interface ModelSummary {
|
||||||
total: number
|
total: number
|
||||||
unpriced: number
|
unpriced: number
|
||||||
missing: { channel: string; model_id: number }[]
|
missing: { channel: string; model_id: number; upstream_model: string }[]
|
||||||
denied_count: number
|
denied_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import Modal from '@/components/ui/Modal.vue'
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import type { Channel, Model, ModelSummary } from '@/types'
|
import type { Model, ModelSummary } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const channels = ref<Channel[]>([])
|
|
||||||
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
|
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
|
||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const editing = ref<Model | null>(null)
|
const editing = ref<Model | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const quickName = ref('')
|
const quickName = ref('')
|
||||||
|
const clearing = ref(false)
|
||||||
|
|
||||||
|
// 未绑定任何渠道的模型:渠道选中的后来被取消,客户端无法调用
|
||||||
|
const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
|
||||||
|
|
||||||
|
async function clearUnused() {
|
||||||
|
if (!unused.value.length) {
|
||||||
|
toast.push('没有未绑定渠道的模型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const names = unused.value.map((m) => m.name)
|
||||||
|
if (!confirm(`确定删除 ${names.length} 个未绑定渠道的模型?\n\n${names.join('\n')}`)) return
|
||||||
|
clearing.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.delete('/admin/models/unused')
|
||||||
|
toast.ok(`已清除 ${data.data.count} 个模型`)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
clearing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function quickAdd() {
|
function quickAdd() {
|
||||||
openCreate()
|
openCreate()
|
||||||
if (quickName.value) form.name = quickName.value.trim()
|
if (quickName.value) form.name = quickName.value.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindOpen = ref(false)
|
|
||||||
const bindModel = ref<Model | null>(null)
|
|
||||||
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
@@ -37,10 +55,9 @@ const form = reactive({
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
const { data } = await http.get('/admin/models')
|
||||||
models.value = m.data.data.items
|
models.value = data.data.items
|
||||||
channels.value = c.data.data.items
|
summary.value = data.data.summary
|
||||||
summary.value = m.data.data.summary
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
}
|
}
|
||||||
@@ -99,37 +116,6 @@ async function removeModel(m: Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBind(m: Model) {
|
|
||||||
bindModel.value = m
|
|
||||||
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
|
|
||||||
bindOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveBinding() {
|
|
||||||
if (!bindModel.value) return
|
|
||||||
try {
|
|
||||||
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
|
|
||||||
...binding,
|
|
||||||
weight: Number(binding.weight),
|
|
||||||
})
|
|
||||||
toast.ok('绑定已添加')
|
|
||||||
bindOpen.value = false
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
toast.err(errMsg(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeBinding(m: Model, bid: number) {
|
|
||||||
try {
|
|
||||||
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
|
|
||||||
toast.ok('绑定已移除')
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
toast.err(errMsg(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -140,7 +126,7 @@ onMounted(load)
|
|||||||
<h1 class="text-lg font-semibold">模型与定价</h1>
|
<h1 class="text-lg font-semibold">模型与定价</h1>
|
||||||
<p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
|
<p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full gap-2 sm:w-auto">
|
<div class="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
||||||
<input
|
<input
|
||||||
v-model="quickName"
|
v-model="quickName"
|
||||||
placeholder="模型名,如 glm-4.7-flash"
|
placeholder="模型名,如 glm-4.7-flash"
|
||||||
@@ -148,26 +134,38 @@ onMounted(load)
|
|||||||
@keyup.enter="quickAdd"
|
@keyup.enter="quickAdd"
|
||||||
/>
|
/>
|
||||||
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
|
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="danger"
|
||||||
|
class="shrink-0 px-1!"
|
||||||
|
:loading="clearing"
|
||||||
|
:disabled="!unused.length"
|
||||||
|
@click="clearUnused"
|
||||||
|
>
|
||||||
|
清除悬空{{ unused.length ? `(${unused.length})` : '' }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 一致性提示:渠道提供的未禁止模型应全部纳入定价 -->
|
<!-- 提示:定价目录 = 渠道选中的模型 + 手动添加的模型 -->
|
||||||
<div v-if="summary.missing.length" class="card border-err/50 p-4">
|
<div v-if="summary.missing.length" class="card border-err/50 p-4">
|
||||||
<p class="text-sm font-medium text-err">以下渠道提供了未纳入定价的模型</p>
|
<p class="text-sm font-medium text-err">以下渠道选中的模型不在定价目录</p>
|
||||||
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
|
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
|
||||||
{{ x.channel }} → 模型 id#{{ x.model_id }}(请在下方添加并定价)
|
{{ x.channel }} → {{ x.upstream_model || '模型 #' + x.model_id }}(请到渠道抽屉重新选中,或手动添加)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
|
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
|
||||||
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个在用模型未定价,网关将按示例价计费
|
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个渠道允许的模型未定价,网关将按示例价计费
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-xs text-muted">已启用渠道提供的全部模型均已纳入定价</p>
|
<p v-else class="text-xs text-muted">定价目录中渠道允许的模型均已定价</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="m in models" :key="m.id" class="card">
|
<div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card' : 'card border-warn/60 bg-warn-soft'">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||||||
|
<Badge v-if="m.channels.length" variant="neutral">渠道允许</Badge>
|
||||||
|
<Badge v-else variant="warn">悬空</Badge>
|
||||||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||||||
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
||||||
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||||||
@@ -178,26 +176,25 @@ onMounted(load)
|
|||||||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="text-xs text-muted hover:text-accent" @click="openBind(m)">绑定渠道</button>
|
|
||||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||||||
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="m.channels.length" class="border-t border-edge px-4 py-2">
|
<div v-if="m.channels.length" class="border-t border-edge px-4 py-2">
|
||||||
|
<p class="mb-1.5 text-[11px] font-medium text-muted">允许渠道(渠道抽屉中管理)</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span
|
<span
|
||||||
v-for="b in m.channels"
|
v-for="b in m.channels"
|
||||||
:key="b.id"
|
:key="b.id"
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
class="inline-flex items-center rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
||||||
>
|
>
|
||||||
{{ b.channel_name }} → {{ b.upstream_model }}
|
{{ b.channel_name }} → {{ b.upstream_model }}
|
||||||
<button class="text-muted hover:text-err" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-muted">
|
<p v-else class="border-t border-edge px-4 py-2 text-xs text-warn">
|
||||||
未绑定渠道,客户端无法调用该模型
|
悬空模型:无任何渠道提供,客户端无法调用
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,24 +219,5 @@ onMounted(load)
|
|||||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- 绑定渠道 -->
|
|
||||||
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-xs text-muted">模型 <span class="font-mono text-accent">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
|
||||||
<label class="block">
|
|
||||||
<span class="mb-1.5 block text-xs font-medium text-muted">渠道</span>
|
|
||||||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
|
||||||
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
|
|
||||||
<Input v-model="binding.weight" label="权重" type="number" />
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<Button variant="ghost" @click="bindOpen = false">取消</Button>
|
|
||||||
<Button @click="saveBinding">绑定</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user