Compare commits

...
4 Commits
Author SHA1 Message Date
SakurasanandClaude c84fea9ef0 记账: Recorder Close 幂等防重复关闭
- main 的 defer Shutdown 与显式 Close 双调用导致停机 panic close of closed channel
- 用 sync.Once 保证只关闭一次

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:15 +08:00
SakurasanandClaude c3dc28fb5b 前端: 仪表盘最近请求 canceled 显示灰色
- 控制台/管理后台仪表盘「最近请求」的 canceled 状态改为 neutral 灰色
- 与用量页保持一致的三态判断(success=ok / canceled=neutral / err)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:10 +08:00
SakurasanandClaude 4137b6fe20 渠道: 远程候选按渠道过滤 + key 掩码 + 移除批量导入端点
- 远程模型候选只排除本渠道已允许的模型,同名模型可被多个渠道各自允许
- 渠道 key 掩码统一 xxxxxxx******Mq4Y(保留前 7 位与后 4 位)
- 移除已弃用的批量导入端点 POST /channels/:id/models/import

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:05 +08:00
SakurasanandClaude d0c879a2ed 模型定价: 悬空模型标记 + 一键清除 + 允许渠道只读
- 无渠道绑定的模型以「悬空」琥珀色徽标 + 卡片着色标记
- 新增「清除悬空」一键删除未绑定渠道的模型(DELETE /models/unused)
- 移除页面内「绑定渠道」弹窗与 chips 的解除按钮,渠道支持列表只在渠道抽屉管理
- 缺失提示带上渠道真实模型名(upstream_model)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:54:59 +08:00
10 changed files with 126 additions and 179 deletions
+13 -3
View File
@@ -13,7 +13,8 @@ import (
) )
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote // AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
// 拉取渠道接口的模型列表(仅预览,不绑定)。 // 拉取渠道接口的模型列表,返回本渠道尚未允许的模型(新增候选)。
// 每个渠道有各自的支持列表:只排除本渠道已允许的模型,其他渠道允许的同名模型仍可作为本渠道候选。
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) { func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -53,10 +54,19 @@ func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
resp.Fail(c, http.StatusBadGateway, "failed to parse model list") resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
return return
} }
// 本渠道已允许的上游模型名:不作为新增候选(其他渠道的模型仍可勾选)
var boundNames []string
h.a.DB.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
boundSet := make(map[string]bool, len(boundNames))
for _, n := range boundNames {
boundSet[strings.TrimSpace(n)] = true
}
items := make([]string, 0, len(list.Data)) items := make([]string, 0, len(list.Data))
for _, m := range list.Data { for _, m := range list.Data {
if strings.TrimSpace(m.ID) != "" { name := strings.TrimSpace(m.ID)
items = append(items, strings.TrimSpace(m.ID)) if name != "" && !boundSet[name] {
items = append(items, name)
} }
} }
resp.OK(c, gin.H{"items": items}) resp.OK(c, gin.H{"items": items})
+7 -81
View File
@@ -13,8 +13,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。 // AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
@@ -28,7 +26,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
for _, ch := range chs { for _, ch := range chs {
masked := "" masked := ""
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 { if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:] masked = maskAPIKey(key)
} else if err == nil { } else if err == nil {
masked = "****" masked = "****"
} }
@@ -371,87 +369,15 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg}) resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
} }
// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import // maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
// 拉取渠道 GET /v1/models,导入模型库并绑定。 // 示例:xxxxxxx******Mq4Y;密钥较短时退化为仅保留后 4 位。
func (h *Handler) AdminImportChannelModels(c *gin.Context) { func maskAPIKey(key string) string {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) if len(key) <= 11 {
if err != nil { return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
return
} }
var ch store.Channel return key[:7] + "******" + key[len(key)-4:]
if err := h.a.DB.First(&ch, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "channel not found")
return
}
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return
}
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
resp2, err := client.Do(req)
if err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
return
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
return
}
var list struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
return
}
if len(list.Data) == 0 {
resp.Fail(c, http.StatusNotFound, "channel returned no models")
return
}
imported := 0
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
for _, item := range list.Data {
name := strings.TrimSpace(item.ID)
if name == "" {
continue
}
var m store.Model
if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{
Name: name, DisplayName: name, Enabled: true,
}).Error; err != nil {
return err
}
// upsert 绑定(upstream_model 默认同名)
var binding store.ChannelModelBinding
err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1}
if err := tx.Create(&binding).Error; err != nil {
return err
}
}
imported++
}
return nil
})
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to import models")
return
}
resp.OK(c, gin.H{"imported": imported})
} }
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
func intOr(p *int, def int) int { func intOr(p *int, def int) int {
if p == nil { if p == nil {
return def return def
+31 -5
View File
@@ -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
View File
@@ -103,7 +103,6 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
admin.PUT("/channels/:id", h.AdminUpdateChannel) admin.PUT("/channels/:id", h.AdminUpdateChannel)
admin.DELETE("/channels/:id", h.AdminDeleteChannel) admin.DELETE("/channels/:id", h.AdminDeleteChannel)
admin.POST("/channels/:id/test", h.AdminTestChannel) admin.POST("/channels/:id/test", h.AdminTestChannel)
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels) admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
admin.GET("/channels/:id/models", h.AdminChannelModels) admin.GET("/channels/:id/models", h.AdminChannelModels)
admin.POST("/channels/:id/models", h.AdminChannelAddModel) admin.POST("/channels/:id/models", h.AdminChannelAddModel)
@@ -111,6 +110,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel) admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
// 模型与定价 // 模型与定价
admin.GET("/models", h.AdminModels) admin.GET("/models", h.AdminModels)
admin.DELETE("/models/unused", h.AdminDeleteUnusedModels)
admin.POST("/models", h.AdminCreateModel) admin.POST("/models", h.AdminCreateModel)
admin.PUT("/models/:id", h.AdminUpdateModel) admin.PUT("/models/:id", h.AdminUpdateModel)
admin.DELETE("/models/:id", h.AdminDeleteModel) admin.DELETE("/models/:id", h.AdminDeleteModel)
+6 -3
View File
@@ -18,6 +18,7 @@ type Recorder struct {
ch chan *store.UsageLog ch chan *store.UsageLog
wg sync.WaitGroup wg sync.WaitGroup
closed chan struct{} closed chan struct{}
once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用)
} }
const batchSize = 32 const batchSize = 32
@@ -45,9 +46,11 @@ func (r *Recorder) Record(l *store.UsageLog) {
} }
func (r *Recorder) Close() { func (r *Recorder) Close() {
close(r.closed) r.once.Do(func() {
r.wg.Wait() close(r.closed)
close(r.ch) r.wg.Wait()
close(r.ch)
})
} }
func (r *Recorder) run() { func (r *Recorder) run() {
+1 -1
View File
@@ -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
} }
+14 -10
View File
@@ -13,6 +13,7 @@ const mappings = ref<ChannelModelMapping[]>([])
const remote = ref<string[]>([]) const remote = ref<string[]>([])
const selected = ref<string[]>([]) const selected = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const fetched = ref(false)
const addForm = reactive({ custom_name: '', upstream_model: '' }) const addForm = reactive({ custom_name: '', upstream_model: '' })
async function load() { async function load() {
@@ -29,8 +30,8 @@ async function fetchRemote() {
try { try {
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`) const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
remote.value = data.data.items remote.value = data.data.items
const bound = new Set(mappings.value.map((m) => m.upstream_model)) selected.value = []
selected.value = remote.value.filter((r) => bound.has(r)) fetched.value = true
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally { } finally {
@@ -39,10 +40,8 @@ async function fetchRemote() {
} }
async function addSelected() { async function addSelected() {
const bound = new Set(mappings.value.map((m) => m.upstream_model))
let added = 0 let added = 0
for (const name of selected.value) { for (const name of selected.value) {
if (bound.has(name)) continue
try { try {
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name }) await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
added++ added++
@@ -50,8 +49,11 @@ async function addSelected() {
/* 单个失败不中断 */ /* 单个失败不中断 */
} }
} }
selected.value = []
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加') toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
await load() await load()
// 已添加的模型已被渠道允许,从拉取候选中移除
await fetchRemote()
} }
async function addManual() { async function addManual() {
@@ -98,9 +100,9 @@ onMounted(load)
<template> <template>
<div class="space-y-3"> <div class="space-y-3">
<!-- 当前支持的模型 --> <!-- 已允许的模型 -->
<div> <div>
<p class="mb-1.5 text-xs font-medium text-muted">当前支持的模型({{ mappings.length }})</p> <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-if="mappings.length" class="flex flex-wrap gap-2">
<div <div
v-for="b in mappings" v-for="b in mappings"
@@ -119,7 +121,7 @@ onMounted(load)
</button> </button>
</div> </div>
</div> </div>
<p v-else class="text-xs text-muted">尚未添加模型</p> <p v-else class="text-xs text-muted">尚未允许任何模型</p>
</div> </div>
<!-- 从接口拉取 + 勾选 --> <!-- 从接口拉取 + 勾选 -->
@@ -135,8 +137,8 @@ onMounted(load)
<label <label
v-for="m in remote" v-for="m in remote"
:key="m" :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="flex cursor-pointer items-center gap-1.5 rounded-md border 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'" :class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 hover:border-edge'"
> >
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" /> <input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
{{ m }} {{ m }}
@@ -148,7 +150,9 @@ onMounted(load)
添加所选({{ selected.length }}) 添加所选({{ selected.length }})
</Button> </Button>
</div> </div>
<p v-else-if="!loading" class="text-xs text-muted">点「拉取」获取渠道接口返回的模型,勾选需要的加入</p> <p v-else-if="!loading" class="text-xs text-muted">
{{ remote.length === 0 && fetched ? '接口返回的模型均已允许,无新增候选' : '点「拉取」获取渠道接口返回的新模型,勾选需要的加入' }}
</p>
</div> </div>
<!-- 手动添加 --> <!-- 手动添加 -->
+51 -73
View File
@@ -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>
+1 -1
View File
@@ -92,7 +92,7 @@ onMounted(load)
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span> <span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge> <Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div> </div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li>
+1 -1
View File
@@ -110,7 +110,7 @@ onMounted(load)
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span> <span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span> <span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge> <Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div> </div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted"> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">