模型黑名单与密钥模型白名单界面、分组填入、删除弹窗排版
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s

This commit is contained in:
Wang Defa
2026-07-12 17:44:46 +08:00
parent 5464d2dfea
commit 94fbe62e4c
10 changed files with 254 additions and 40 deletions
+111 -13
View File
@@ -1,23 +1,32 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { NButton, useDialog } from 'naive-ui'
import { computed, ref } from 'vue'
import { listAiChannels, listAiKeys, listAiModels } from '@/api/aigateway'
import {
addAiBlacklist,
listAiBlacklist,
listAiChannels,
listAiKeys,
listAiModels,
removeAiBlacklist,
} from '@/api/aigateway'
import AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
import type { AiModelBlacklistItem } from '@/types/api'
const message = useToast()
const dialog = useDialog()
const keys = useAsync(listAiKeys)
const channels = useAsync(listAiChannels)
const models = useAsync(listAiModels)
const blacklist = useAsync(listAiBlacklist)
const baseUrl = `${window.location.origin}/ai/v1`
const endpoints = [
{ method: 'POST', path: '/chat/completions', note: 'OpenAI · 流式/非流式' },
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
{ method: 'POST', path: '/embeddings', note: 'OpenAI Embeddings · 向量化' },
@@ -43,9 +52,12 @@ const modelGroups = computed(() => {
.map(([vendor, ids]) => ({ vendor, label: VENDOR_LABEL[vendor] ?? vendor, ids: ids.sort() }))
})
/** 手风琴:同时只展开一个厂商,配合展开区限高控制卡片总高度 */
/** 手风琴:同时只展开一个分组(厂商或黑名单),配合展开区限高控制卡片总高度 */
const expandedVendor = ref<string | null>(null)
/** 黑名单在手风琴中的分组 key(厂商 key 取自模型名前缀,不会与此冲突) */
const BLACKLIST_KEY = '__blacklist__'
function toggleVendor(vendor: string) {
expandedVendor.value = expandedVendor.value === vendor ? null : vendor
}
@@ -71,9 +83,43 @@ async function copyModel(id: string) {
}
}
function refreshModels() {
void models.run({ silent: true })
void blacklist.run({ silent: true })
}
function refreshChannels() {
void channels.run({ silent: true })
void models.run({ silent: true })
refreshModels()
}
/** 拉黑:删除全部渠道缓存中的该模型,后续同步 / 探测均过滤 */
function banModel(id: string) {
dialog.warning({
title: '加入模型黑名单',
content: `拉黑「${id}」?全部渠道立即剔除该模型,后续同步与探测不再收录。`,
positiveText: '拉黑',
negativeText: '取消',
onPositiveClick: async () => {
try {
await addAiBlacklist(id)
message.success(`已拉黑:${id}`)
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
refreshModels()
},
})
}
async function unbanModel(b: AiModelBlacklistItem) {
try {
await removeAiBlacklist(b.id)
message.success(`已移出黑名单:${b.name},重新同步渠道后恢复`)
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
refreshModels()
}
</script>
@@ -114,7 +160,7 @@ function refreshChannels() {
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">可用模型</div>
<div class="mt-0.5 text-xs text-ink-3">按厂商分组;点击厂商展开,点击模型名复制</div>
<div class="mt-0.5 text-xs text-ink-3">按厂商分组;点击厂商展开,点击模型名复制, 加入黑名单</div>
</div>
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} </span>
</div>
@@ -144,16 +190,67 @@ function refreshChannels() {
v-if="expandedVendor === g.vendor"
class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto pb-2 pl-5"
>
<button
<span
v-for="id in g.ids"
:key="id"
type="button"
class="mono cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-[11.5px] hover:border-accent hover:text-accent"
:title="`复制 ${id}`"
@click="copyModel(id)"
class="mono inline-flex items-center overflow-hidden rounded-[5px] border border-line bg-card text-[11.5px]"
>
{{ id }}
</button>
<button
type="button"
class="cursor-pointer py-0.5 pr-1 pl-2 hover:text-accent"
:title="`复制 ${id}`"
@click="copyModel(id)"
>
{{ id }}
</button>
<button
type="button"
class="cursor-pointer self-stretch pr-1.5 pl-0.5 text-ink-3 hover:text-err"
:title="`加入黑名单:${id}`"
@click="banModel(id)"
>
<svg
class="h-2.5 w-2.5"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</span>
</div>
</div>
<div v-if="blacklist.data.value?.length" class="border-b border-line-soft py-1 last:border-b-0">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-left"
@click="toggleVendor(BLACKLIST_KEY)"
>
<svg
class="h-3 w-3 flex-none text-ink-3 transition-transform"
:class="expandedVendor === BLACKLIST_KEY ? 'rotate-90' : ''"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span class="text-[13px] font-medium">模型黑名单</span>
<span class="text-xs text-ink-3">{{ blacklist.data.value.length }} </span>
</button>
<div v-if="expandedVendor === BLACKLIST_KEY" class="pb-2 pl-5">
<div class="mb-1.5 text-xs text-ink-3">同步与探测均过滤;点击移出,重新同步渠道后恢复入池</div>
<div class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto">
<button
v-for="b in blacklist.data.value"
:key="b.id"
type="button"
class="mono cursor-pointer rounded-[5px] border border-err/30 bg-err/5 px-2 py-0.5 text-[11.5px] text-ink-3 line-through hover:border-err hover:text-err"
:title="`移出黑名单:${b.name}`"
@click="unbanModel(b)"
>
{{ b.name }}
</button>
</div>
</div>
</div>
<div v-if="models.data.value && !models.data.value.length" class="py-2 text-xs text-ink-3">
@@ -170,6 +267,7 @@ function refreshChannels() {
:keys="keys.data.value ?? []"
:loading="keys.loading.value"
:groups="groups"
:models="models.data.value ?? []"
@changed="keys.run({ silent: true })"
/>
+9 -2
View File
@@ -140,8 +140,15 @@ async function remove() {
<template #trigger>
<NButton size="small" type="error" ghost>删除租户</NButton>
</template>
删除{{ config.data.value.alias }}将清理本面板中的关联任务快照回传事件与 Webhook
密钥告警及 AI 渠道数据OCI 云端资源不会被删除云端日志回传链路请先在其他页销毁
<div class="max-w-80 text-[13px] leading-relaxed">
<div class="font-medium">删除{{ config.data.value.alias }}</div>
<div class="mt-1 text-xs text-ink-3">
将清理面板内关联数据后台任务测活 / 成本快照回传事件Webhook 密钥告警规则与 AI 渠道
</div>
<div class="mt-0.5 text-xs text-ink-3">
OCI 云端资源不受影响云端日志回传链路请先在其他页销毁
</div>
</div>
</NPopconfirm>
</div>
+13 -1
View File
@@ -222,7 +222,19 @@ function renderActions(row: OciConfigSummary): VNodeChild {
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () =>
`删除「${row.alias}」?将清理本面板中的关联任务、快照、回传事件与 Webhook 密钥、告警及 AI 渠道数据;OCI 云端资源不会被删除(云端日志回传链路请先在“其他”页销毁)。`,
h('div', { class: 'max-w-80 text-[13px] leading-relaxed' }, [
h('div', { class: 'font-medium' }, `删除「${row.alias}」?`),
h(
'div',
{ class: 'mt-1 text-xs text-ink-3' },
'将清理面板内关联数据:后台任务、测活 / 成本快照、回传事件、Webhook 密钥、告警规则与 AI 渠道。',
),
h(
'div',
{ class: 'mt-0.5 text-xs text-ink-3' },
'OCI 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。',
),
]),
},
),
])