模型黑名单与密钥模型白名单界面、分组填入、删除弹窗排版
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
+5 -1
View File
@@ -4,6 +4,7 @@ import { computed, h, reactive, ref } from 'vue'
import { createAiChannel, deleteAiChannel, probeAiChannel, syncAiChannelModels, updateAiChannel } from '@/api/aigateway'
import { listCachedRegions } from '@/api/configs'
import GroupChips from '@/components/ai/GroupChips.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
@@ -15,9 +16,11 @@ import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
import type { AiChannel, AiProbeStatus } from '@/types/api'
defineProps<{ channels: AiChannel[]; loading: boolean }>()
const props = defineProps<{ channels: AiChannel[]; loading: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
const toast = useToast()
const dialog = useDialog()
const scope = useScopeStore()
@@ -233,6 +236,7 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
<div class="grid grid-cols-3 gap-3">
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
<NInput v-model:value="form.group" placeholder="默认" />
<GroupChips :groups="groupHints" @pick="form.group = $event" />
</FormField>
<FormField label="优先级" hint="数字小优先">
<AppInputNumber v-model:value="form.priority" :min="1" />
+46 -20
View File
@@ -1,17 +1,18 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
import GroupChips from '@/components/ai/GroupChips.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtTime } from '@/composables/useFormat'
import type { AiKey } from '@/types/api'
import type { AiKey, AiModel } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[] }>()
const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[]; models: AiModel[] }>()
const emit = defineEmits<{ changed: [] }>()
const message = useToast()
@@ -21,20 +22,23 @@ const dialog = useDialog()
const showForm = ref(false)
const editing = ref<AiKey | null>(null)
const submitting = ref(false)
const form = reactive({ name: '', value: '', group: '' })
const form = reactive({ name: '', value: '', group: '', models: [] as string[] })
function openCreate() {
editing.value = null
Object.assign(form, { name: '', value: '', group: '' })
Object.assign(form, { name: '', value: '', group: '', models: [] })
showForm.value = true
}
function openEdit(key: AiKey) {
editing.value = key
Object.assign(form, { name: key.name, value: '', group: key.group })
Object.assign(form, { name: key.name, value: '', group: key.group, models: key.models ?? [] })
showForm.value = true
}
/** 限制模型下拉选项:聚合模型列表;tag 模式允许手输未同步模型 */
const modelOptions = computed(() => props.models.map((m) => ({ label: m.id, value: m.id })))
// 创建成功后明文只在此弹窗展示一次
const createdKey = ref('')
const showCreated = ref(false)
@@ -43,10 +47,13 @@ async function submit() {
submitting.value = true
try {
if (editing.value) {
await updateAiKey(editing.value.id, { name: form.name, group: form.group })
await updateAiKey(editing.value.id, { name: form.name, group: form.group, models: form.models })
message.success('已更新密钥')
} else {
const { key } = await createAiKey({ name: form.name, value: form.value || undefined, group: form.group || undefined })
const { key } = await createAiKey({
name: form.name, value: form.value || undefined, group: form.group || undefined,
models: form.models.length ? form.models : undefined,
})
createdKey.value = key
showCreated.value = true
}
@@ -118,6 +125,21 @@ function confirmRemove(key: AiKey) {
const groupHints = computed(() => props.groups.filter(Boolean))
// modelsCell 渲染模型白名单列:空 = 不限;非空显示数量,悬浮列明细
function modelsCell(r: AiKey) {
const list = r.models ?? []
if (!list.length) return h('span', { class: 'text-xs text-ink-3' }, '不限')
return h(
NTooltip,
{ style: { maxWidth: '320px' } },
{
trigger: () =>
h('span', { class: 'cursor-help rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, `${list.length}`),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, list.join('\n')),
},
)
}
const columns = computed<DataTableColumns<AiKey>>(() => [
{ title: '名称', key: 'name', width: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
{ title: '密钥', key: 'tail', width: 120, render: (r) => h('span', { class: 'mono text-[13px] text-ink-2' }, `……${r.tail}`) },
@@ -127,6 +149,7 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
? h('span', { class: 'rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, r.group)
: h('span', { class: 'text-xs text-ink-3' }, '不限'),
},
{ title: '模型', key: 'models', width: 90, render: modelsCell },
{
title: '状态', key: 'enabled', width: 100,
render: (r) => h(StatusBadge, r.enabled ? { kind: 'run', label: '启用' } : { kind: 'stop', label: '已禁用' }),
@@ -170,18 +193,21 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
</FormField>
<FormField label="渠道分组" hint="选择分组后,该密钥的调用只在该分组的渠道内负载均衡;留空不限分组">
<NInput v-model:value="form.group" placeholder="留空 = 不限(全部渠道)" />
<div v-if="groupHints.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span class="text-xs text-ink-3">现有分组:</span>
<button
v-for="g in groupHints"
:key="g"
type="button"
class="cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-xs text-ink-2 hover:border-accent hover:text-accent"
@click="form.group = g"
>
{{ g }}
</button>
</div>
<GroupChips :groups="groupHints" @pick="form.group = $event" />
</FormField>
<FormField
label="限制模型"
hint="非空时该密钥仅能调用所选模型,/v1/models 同步只返回交集;可手输未同步的模型名;留空不限"
>
<NSelect
v-model:value="form.models"
:options="modelOptions"
multiple
filterable
clearable
tag
placeholder="留空 = 不限(全部模型)"
/>
</FormField>
<FormField v-if="!editing" label="自定义密钥值" hint="至少 8 位;仅测试场景建议自定义">
<NInput v-model:value="form.value" placeholder="留空自动生成 sk- 开头随机密钥(推荐)" />
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
// 现有分组提示行:点击 chip 将分组名填入外部输入框;无分组时整行不渲染
defineProps<{ groups: string[] }>()
defineEmits<{ pick: [group: string] }>()
</script>
<template>
<div v-if="groups.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span class="text-xs text-ink-3">现有分组:</span>
<button
v-for="g in groups"
:key="g"
type="button"
class="cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-xs text-ink-2 hover:border-accent hover:text-accent"
@click="$emit('pick', g)"
>
{{ g }}
</button>
</div>
</template>