模型黑名单与密钥模型白名单界面、分组填入、删除弹窗排版
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
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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 { 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" />
+46 -20
View File
@@ -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- 开头随机密钥(推荐)" />
+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>
+9
View File
@@ -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
View File
@@ -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 })"
/> />
+9 -2
View File
@@ -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>
+13 -1
View File
@@ -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 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。',
),
]),
}, },
), ),
]) ])