Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94fbe62e4c |
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关页「模型黑名单」管理:模型列表按厂商手风琴分组展示,可一键拉黑(全渠道剔除)、移除后重新同步渠道恢复
|
||||||
|
- AI 密钥模型白名单编辑:创建 / 编辑密钥时可限定放行的模型列表(空 = 不限)
|
||||||
|
- 渠道 / 密钥表单「现有分组」chips:点击即填入分组输入框
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 租户删除确认弹窗分段排版(标题 / 面板清理范围 / 云端提示),列表与详情页文案一致
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-10
|
## [0.1.0] - 2026-07-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "oci-portal-dash",
|
"name": "oci-portal-dash",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+28
-2
@@ -1,5 +1,13 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import type { AiCallLog, AiChannel, AiContentLog, AiKey, AiModel, AiModelCacheItem } from '@/types/api'
|
import type {
|
||||||
|
AiCallLog,
|
||||||
|
AiChannel,
|
||||||
|
AiContentLog,
|
||||||
|
AiKey,
|
||||||
|
AiModel,
|
||||||
|
AiModelBlacklistItem,
|
||||||
|
AiModelCacheItem,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
// ---- 网关密钥 ----
|
// ---- 网关密钥 ----
|
||||||
|
|
||||||
@@ -11,6 +19,8 @@ export interface CreateAiKeyRequest {
|
|||||||
name: string
|
name: string
|
||||||
value?: string
|
value?: string
|
||||||
group?: string
|
group?: string
|
||||||
|
/** 模型白名单;缺省/空 = 不限 */
|
||||||
|
models?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建密钥;key 为明文,仅本次响应返回 */
|
/** 创建密钥;key 为明文,仅本次响应返回 */
|
||||||
@@ -20,7 +30,7 @@ export function createAiKey(body: CreateAiKeyRequest): Promise<{ key: string; it
|
|||||||
|
|
||||||
export function updateAiKey(
|
export function updateAiKey(
|
||||||
id: number,
|
id: number,
|
||||||
body: { name?: string; enabled?: boolean; group?: string },
|
body: { name?: string; enabled?: boolean; group?: string; models?: string[] },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return request(`/ai-keys/${id}`, { method: 'PUT', body })
|
return request(`/ai-keys/${id}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
@@ -78,6 +88,22 @@ export function listAiModels(): Promise<AiModel[]> {
|
|||||||
return request<{ items: AiModel[] }>('/ai-models').then((r) => r.items)
|
return request<{ items: AiModel[] }>('/ai-models').then((r) => r.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 模型黑名单 ----
|
||||||
|
|
||||||
|
export function listAiBlacklist(): Promise<AiModelBlacklistItem[]> {
|
||||||
|
return request<{ items: AiModelBlacklistItem[] }>('/ai-blacklist').then((r) => r.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤 */
|
||||||
|
export function addAiBlacklist(name: string): Promise<void> {
|
||||||
|
return request('/ai-blacklist', { method: 'POST', body: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移出黑名单:缓存不回填,重新同步 / 探测后恢复入池 */
|
||||||
|
export function removeAiBlacklist(id: number): Promise<void> {
|
||||||
|
return request(`/ai-blacklist/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
export function listAiCallLogs(params: {
|
export function listAiCallLogs(params: {
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, h, reactive, ref } from 'vue'
|
|||||||
|
|
||||||
import { createAiChannel, deleteAiChannel, probeAiChannel, syncAiChannelModels, updateAiChannel } from '@/api/aigateway'
|
import { createAiChannel, deleteAiChannel, probeAiChannel, syncAiChannelModels, updateAiChannel } from '@/api/aigateway'
|
||||||
import { listCachedRegions } from '@/api/configs'
|
import { listCachedRegions } from '@/api/configs'
|
||||||
|
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||||
import FormField from '@/components/FormField.vue'
|
import FormField from '@/components/FormField.vue'
|
||||||
import FormModal from '@/components/FormModal.vue'
|
import FormModal from '@/components/FormModal.vue'
|
||||||
@@ -15,9 +16,11 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { useScopeStore } from '@/stores/scope'
|
import { useScopeStore } from '@/stores/scope'
|
||||||
import type { AiChannel, AiProbeStatus } from '@/types/api'
|
import type { AiChannel, AiProbeStatus } from '@/types/api'
|
||||||
|
|
||||||
defineProps<{ channels: AiChannel[]; loading: boolean }>()
|
const props = defineProps<{ channels: AiChannel[]; loading: boolean }>()
|
||||||
const emit = defineEmits<{ changed: [] }>()
|
const emit = defineEmits<{ changed: [] }>()
|
||||||
|
|
||||||
|
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const scope = useScopeStore()
|
const scope = useScopeStore()
|
||||||
@@ -233,6 +236,7 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
|
|||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
|
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
|
||||||
<NInput v-model:value="form.group" placeholder="默认" />
|
<NInput v-model:value="form.group" placeholder="默认" />
|
||||||
|
<GroupChips :groups="groupHints" @pick="form.group = $event" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="优先级" hint="数字小优先">
|
<FormField label="优先级" hint="数字小优先">
|
||||||
<AppInputNumber v-model:value="form.priority" :min="1" />
|
<AppInputNumber v-model:value="form.priority" :min="1" />
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, h, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
|
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
|
||||||
|
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||||
import FormField from '@/components/FormField.vue'
|
import FormField from '@/components/FormField.vue'
|
||||||
import FormModal from '@/components/FormModal.vue'
|
import FormModal from '@/components/FormModal.vue'
|
||||||
import StatusBadge from '@/components/StatusBadge.vue'
|
import StatusBadge from '@/components/StatusBadge.vue'
|
||||||
import { fmtTime } from '@/composables/useFormat'
|
import { fmtTime } from '@/composables/useFormat'
|
||||||
import type { AiKey } from '@/types/api'
|
import type { AiKey, AiModel } from '@/types/api'
|
||||||
import { useToast } from '@/composables/useToast'
|
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 emit = defineEmits<{ changed: [] }>()
|
||||||
|
|
||||||
const message = useToast()
|
const message = useToast()
|
||||||
@@ -21,20 +22,23 @@ const dialog = useDialog()
|
|||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editing = ref<AiKey | null>(null)
|
const editing = ref<AiKey | null>(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const form = reactive({ name: '', value: '', group: '' })
|
const form = reactive({ name: '', value: '', group: '', models: [] as string[] })
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, { name: '', value: '', group: '' })
|
Object.assign(form, { name: '', value: '', group: '', models: [] })
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(key: AiKey) {
|
function openEdit(key: AiKey) {
|
||||||
editing.value = key
|
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
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 限制模型下拉选项:聚合模型列表;tag 模式允许手输未同步模型 */
|
||||||
|
const modelOptions = computed(() => props.models.map((m) => ({ label: m.id, value: m.id })))
|
||||||
|
|
||||||
// 创建成功后明文只在此弹窗展示一次
|
// 创建成功后明文只在此弹窗展示一次
|
||||||
const createdKey = ref('')
|
const createdKey = ref('')
|
||||||
const showCreated = ref(false)
|
const showCreated = ref(false)
|
||||||
@@ -43,10 +47,13 @@ async function submit() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editing.value) {
|
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('已更新密钥')
|
message.success('已更新密钥')
|
||||||
} else {
|
} 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
|
createdKey.value = key
|
||||||
showCreated.value = true
|
showCreated.value = true
|
||||||
}
|
}
|
||||||
@@ -118,6 +125,21 @@ function confirmRemove(key: AiKey) {
|
|||||||
|
|
||||||
const groupHints = computed(() => props.groups.filter(Boolean))
|
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>>(() => [
|
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: '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}`) },
|
{ 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: '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' }, '不限'),
|
: h('span', { class: 'text-xs text-ink-3' }, '不限'),
|
||||||
},
|
},
|
||||||
|
{ title: '模型', key: 'models', width: 90, render: modelsCell },
|
||||||
{
|
{
|
||||||
title: '状态', key: 'enabled', width: 100,
|
title: '状态', key: 'enabled', width: 100,
|
||||||
render: (r) => h(StatusBadge, r.enabled ? { kind: 'run', label: '启用' } : { kind: 'stop', label: '已禁用' }),
|
render: (r) => h(StatusBadge, r.enabled ? { kind: 'run', label: '启用' } : { kind: 'stop', label: '已禁用' }),
|
||||||
@@ -170,18 +193,21 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="渠道分组" hint="选择分组后,该密钥的调用只在该分组的渠道内负载均衡;留空不限分组">
|
<FormField label="渠道分组" hint="选择分组后,该密钥的调用只在该分组的渠道内负载均衡;留空不限分组">
|
||||||
<NInput v-model:value="form.group" placeholder="留空 = 不限(全部渠道)" />
|
<NInput v-model:value="form.group" placeholder="留空 = 不限(全部渠道)" />
|
||||||
<div v-if="groupHints.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
<GroupChips :groups="groupHints" @pick="form.group = $event" />
|
||||||
<span class="text-xs text-ink-3">现有分组:</span>
|
</FormField>
|
||||||
<button
|
<FormField
|
||||||
v-for="g in groupHints"
|
label="限制模型"
|
||||||
:key="g"
|
hint="非空时该密钥仅能调用所选模型,/v1/models 同步只返回交集;可手输未同步的模型名;留空不限"
|
||||||
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"
|
<NSelect
|
||||||
@click="form.group = g"
|
v-model:value="form.models"
|
||||||
>
|
:options="modelOptions"
|
||||||
{{ g }}
|
multiple
|
||||||
</button>
|
filterable
|
||||||
</div>
|
clearable
|
||||||
|
tag
|
||||||
|
placeholder="留空 = 不限(全部模型)"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField v-if="!editing" label="自定义密钥值" hint="至少 8 位;仅测试场景建议自定义">
|
<FormField v-if="!editing" label="自定义密钥值" hint="至少 8 位;仅测试场景建议自定义">
|
||||||
<NInput v-model:value="form.value" placeholder="留空自动生成 sk- 开头随机密钥(推荐)" />
|
<NInput v-model:value="form.value" placeholder="留空自动生成 sk- 开头随机密钥(推荐)" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -946,6 +946,8 @@ export interface AiKey {
|
|||||||
name: string
|
name: string
|
||||||
tail: string
|
tail: string
|
||||||
group: string
|
group: string
|
||||||
|
/** 模型白名单;null/空 = 不限,非空时网关仅放行列表内模型 */
|
||||||
|
models: string[] | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
lastUsedAt: string | null
|
lastUsedAt: string | null
|
||||||
/** 内容日志开启截止时间;null = 永关(默认,红线),开启必须限时到期自动停写 */
|
/** 内容日志开启截止时间;null = 永关(默认,红线),开启必须限时到期自动停写 */
|
||||||
@@ -991,6 +993,13 @@ export interface AiModelCacheItem {
|
|||||||
syncedAt: string
|
syncedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模型黑名单条目:拉黑即全渠道剔除,同步 / 探测不再入库 */
|
||||||
|
export interface AiModelBlacklistItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 调用日志:仅元数据与 token 用量 */
|
/** 调用日志:仅元数据与 token 用量 */
|
||||||
export interface AiCallLog {
|
export interface AiCallLog {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
+111
-13
@@ -1,23 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton } from 'naive-ui'
|
import { NButton, useDialog } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
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 AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
|
||||||
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
|
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
|
||||||
import { useAsync } from '@/composables/useAsync'
|
import { useAsync } from '@/composables/useAsync'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import type { AiModelBlacklistItem } from '@/types/api'
|
||||||
|
|
||||||
const message = useToast()
|
const message = useToast()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
const keys = useAsync(listAiKeys)
|
const keys = useAsync(listAiKeys)
|
||||||
const channels = useAsync(listAiChannels)
|
const channels = useAsync(listAiChannels)
|
||||||
const models = useAsync(listAiModels)
|
const models = useAsync(listAiModels)
|
||||||
|
const blacklist = useAsync(listAiBlacklist)
|
||||||
|
|
||||||
const baseUrl = `${window.location.origin}/ai/v1`
|
const baseUrl = `${window.location.origin}/ai/v1`
|
||||||
|
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
{ method: 'POST', path: '/chat/completions', note: 'OpenAI · 流式/非流式' },
|
|
||||||
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
|
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
|
||||||
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
|
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
|
||||||
{ method: 'POST', path: '/embeddings', note: 'OpenAI Embeddings · 向量化' },
|
{ 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() }))
|
.map(([vendor, ids]) => ({ vendor, label: VENDOR_LABEL[vendor] ?? vendor, ids: ids.sort() }))
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 手风琴:同时只展开一个厂商,配合展开区限高控制卡片总高度 */
|
/** 手风琴:同时只展开一个分组(厂商或黑名单),配合展开区限高控制卡片总高度 */
|
||||||
const expandedVendor = ref<string | null>(null)
|
const expandedVendor = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 黑名单在手风琴中的分组 key(厂商 key 取自模型名前缀,不会与此冲突) */
|
||||||
|
const BLACKLIST_KEY = '__blacklist__'
|
||||||
|
|
||||||
function toggleVendor(vendor: string) {
|
function toggleVendor(vendor: string) {
|
||||||
expandedVendor.value = expandedVendor.value === vendor ? null : vendor
|
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() {
|
function refreshChannels() {
|
||||||
void channels.run({ silent: true })
|
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>
|
</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 class="flex items-center justify-between border-b border-line-soft px-4.5 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold">可用模型</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>
|
</div>
|
||||||
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} 个</span>
|
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} 个</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,16 +190,67 @@ function refreshChannels() {
|
|||||||
v-if="expandedVendor === g.vendor"
|
v-if="expandedVendor === g.vendor"
|
||||||
class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto pb-2 pl-5"
|
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"
|
v-for="id in g.ids"
|
||||||
:key="id"
|
:key="id"
|
||||||
type="button"
|
class="mono inline-flex items-center overflow-hidden rounded-[5px] border border-line bg-card text-[11.5px]"
|
||||||
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)"
|
|
||||||
>
|
>
|
||||||
{{ 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>
|
</div>
|
||||||
<div v-if="models.data.value && !models.data.value.length" class="py-2 text-xs text-ink-3">
|
<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 ?? []"
|
:keys="keys.data.value ?? []"
|
||||||
:loading="keys.loading.value"
|
:loading="keys.loading.value"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
|
:models="models.data.value ?? []"
|
||||||
@changed="keys.run({ silent: true })"
|
@changed="keys.run({ silent: true })"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,15 @@ async function remove() {
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton size="small" type="error" ghost>删除租户</NButton>
|
<NButton size="small" type="error" ghost>删除租户</NButton>
|
||||||
</template>
|
</template>
|
||||||
删除「{{ config.data.value.alias }}」?将清理本面板中的关联任务、快照、回传事件与 Webhook
|
<div class="max-w-80 text-[13px] leading-relaxed">
|
||||||
密钥、告警及 AI 渠道数据;OCI 云端资源不会被删除(云端日志回传链路请先在“其他”页销毁)。
|
<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>
|
</NPopconfirm>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,19 @@ function renderActions(row: OciConfigSummary): VNodeChild {
|
|||||||
trigger: () =>
|
trigger: () =>
|
||||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
||||||
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 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。',
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|||||||
Reference in New Issue
Block a user