Hide capability tags from UI

This commit is contained in:
fawney19
2026-05-19 19:10:30 +08:00
parent 124077a0a1
commit 57655bdb25
7 changed files with 215 additions and 198 deletions

View File

@@ -9,6 +9,17 @@ export interface IPBlacklistEntry {
ttl?: number
}
export interface BlacklistListEntry {
ip_address: string
reason: string
ttl_seconds?: number | null
}
export interface BlacklistResponse {
items: BlacklistListEntry[]
total: number
}
export interface IPWhitelistEntry {
ip_address: string
}
@@ -50,6 +61,14 @@ export const blacklistApi = {
async getStats(): Promise<BlacklistStats> {
const response = await apiClient.get('/api/admin/security/ip/blacklist/stats')
return response.data
},
/**
* 获取黑名单列表
*/
async getList(): Promise<BlacklistResponse> {
const response = await apiClient.get('/api/admin/security/ip/blacklist')
return response.data
}
}

View File

@@ -291,25 +291,6 @@
</div>
</div>
<!-- 能力标签 -->
<div v-if="availableCapabilities.length > 0">
<Label class="text-xs mb-1.5 block">能力标签</Label>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cap in availableCapabilities"
:key="cap.name"
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-sm transition-colors"
:class="form.capabilities[cap.name]
? 'bg-primary/10 border-primary/50 text-primary'
: 'bg-card border-border hover:bg-muted/50 text-muted-foreground'"
@click="form.capabilities[cap.name] = !form.capabilities[cap.name]"
>
{{ cap.display_name }}
</button>
</div>
</div>
<!-- 自动获取模型 -->
<div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="flex items-center justify-between">
@@ -376,7 +357,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import {
Dialog,
Button,
@@ -394,17 +375,14 @@ import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import JsonImportInput from '@/components/common/JsonImportInput.vue'
import {
addProviderKey,
updateProviderKey,
getAllCapabilities,
sortApiFormats,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
type ProviderEndpoint,
type CapabilityDefinition,
type ProviderType
} from '@/api/endpoints'
import { formatApiFormat, normalizeApiFormatAlias, formatSupportsAuthOverride } from '@/api/endpoints/types/api-format'
@@ -710,9 +688,6 @@ const authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 新增密钥时默认不自动开启上游模型获取
const defaultAutoFetchModels = computed(() => false)
@@ -732,7 +707,6 @@ const form = ref({
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>,
auto_fetch_models: false,
model_include_patterns_text: '', // 包含规则文本(逗号分隔)
model_exclude_patterns_text: '' // 排除规则文本(逗号分隔)
@@ -789,19 +763,6 @@ watch(
{ deep: true, immediate: true }
)
// 加载能力列表
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (err) {
log.error('Failed to load capabilities:', err)
}
}
onMounted(() => {
loadCapabilities()
})
// API 格式切换
function toggleApiFormat(format: string) {
const index = form.value.api_formats.indexOf(format)
@@ -840,7 +801,6 @@ function resetForm() {
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {},
auto_fetch_models: defaultAutoFetchModels.value,
model_include_patterns_text: '',
model_exclude_patterns_text: ''
@@ -892,7 +852,6 @@ function loadKeyData() {
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) },
auto_fetch_models: props.editingKey.auto_fetch_models ?? false,
model_include_patterns_text: (props.editingKey.model_include_patterns || []).join(', '),
model_exclude_patterns_text: (props.editingKey.model_exclude_patterns || []).join(', ')
@@ -979,15 +938,6 @@ async function handleSave() {
return
}
// 过滤出有效的能力配置(只包含值为 true 的)
const activeCapabilities: Record<string, boolean> = {}
for (const [key, value] of Object.entries(form.value.capabilities)) {
if (value) {
activeCapabilities[key] = true
}
}
const capabilitiesData = Object.keys(activeCapabilities).length > 0 ? activeCapabilities : null
saving.value = true
try {
// 准备 rate_multipliers 数据:只保留已选中格式的倍率配置
@@ -1025,7 +975,6 @@ async function handleSave() {
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData,
allowed_models: shouldClearAllowedModels ? null : undefined,
auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
@@ -1059,7 +1008,6 @@ async function handleSave() {
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined,
auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)

View File

@@ -341,36 +341,6 @@
</span>
</span>
</div>
<div
v-if="activeCapabilities.length > 0"
class="info-item"
>
<span class="info-label">请求能力</span>
<span class="info-value">
<span class="capability-tags">
<span
v-for="cap in activeCapabilities"
:key="`required-${cap}`"
class="capability-tag active"
>{{ formatCapabilityLabel(cap) }}</span>
</span>
</span>
</div>
<div
v-if="keyCapabilities.length > 0"
class="info-item"
>
<span class="info-label">Key 能力</span>
<span class="info-value">
<span class="capability-tags">
<span
v-for="cap in keyCapabilities"
:key="`key-${cap}`"
class="capability-tag"
>{{ formatCapabilityLabel(cap) }}</span>
</span>
</span>
</div>
</div>
<div
@@ -1489,26 +1459,6 @@ const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(
return Object.keys(display).length > 0 ? display : null
})
// 计算当前尝试启用的能力标签(请求需要的能力)
const activeCapabilities = computed(() => {
if (!currentAttempt.value?.required_capabilities) return []
const caps = currentAttempt.value.required_capabilities
// 只返回值为 true 的能力
return Object.entries(caps)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
})
// 计算当前 Key 支持的能力标签
const keyCapabilities = computed(() => {
if (!currentAttempt.value?.key_capabilities) return []
const caps = currentAttempt.value.key_capabilities
// 只返回值为 true 的能力
return Object.entries(caps)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
})
const hasActiveImageProgress = computed(() => {
return rawTimeline.value.some((candidate) => {
const progress = normalizeImageProgress(candidate.image_progress)
@@ -1542,20 +1492,6 @@ const formatAuthTypeWithPlan = (authType: string, planType?: string): string =>
return typeName
}
// 格式化能力标签显示
const formatCapabilityLabel = (cap: string): string => {
const labels: Record<string, string> = {
'cache_1h': '1h缓存',
'cache_5min': '5min缓存',
'context_1m': '1M上下文',
'context_200k': '200K上下文',
'extended_thinking': '深度思考',
'vision': '视觉',
'function_calling': '函数调用',
}
return labels[cap] || cap
}
const poolSelectionLabel = (reason: string): string => {
const labels: Record<string, string> = {
sticky: '粘性会话',
@@ -2502,35 +2438,6 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
color: hsl(var(--muted-foreground));
}
/* 能力标签 */
.capability-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
.capability-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
white-space: nowrap;
border-radius: 4px;
background: transparent;
border: 1px dashed hsl(var(--border));
transition: all 0.15s ease;
}
/* 被请求使用的能力(高亮边框) */
.capability-tag.active {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.08);
}
.image-progress-block {
margin-top: 0.875rem;
padding: 0.75rem;

View File

@@ -10,7 +10,7 @@
黑名单 IP 数量
</p>
<h3 class="text-2xl font-bold mt-2">
{{ blacklistStats.total || 0 }}
{{ blacklistData.total || blacklistStats.total || 0 }}
</h3>
</div>
<div class="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
@@ -66,7 +66,7 @@
</Button>
<RefreshButton
:loading="loadingBlacklist"
@click="loadBlacklistStats"
@click="loadBlacklist"
/>
</div>
</div>
@@ -85,16 +85,33 @@
>
<div
v-if="!blacklistStats.available"
class="mb-4 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-muted-foreground"
>
<div class="flex items-start gap-3">
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p class="font-medium text-foreground">
黑名单状态不可用列表可能不是最新
</p>
<p class="mt-1 text-xs">
{{ blacklistStats.error }}
</p>
</div>
</div>
</div>
<div
v-if="blacklistListError"
class="text-center py-8 text-muted-foreground"
>
<AlertCircle class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Redis 不可用无法管理黑名单</p>
<p>无法获取黑名单列表</p>
<p class="text-xs mt-1">
{{ blacklistStats.error }}
{{ blacklistListError }}
</p>
</div>
<div
v-else-if="blacklistStats.total === 0"
v-else-if="blacklistData.items.length === 0"
class="text-center py-8 text-muted-foreground"
>
<ShieldX class="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -102,9 +119,79 @@
</div>
<div
v-else
class="text-sm text-muted-foreground"
class="space-y-4"
>
当前共有 <span class="font-semibold text-foreground">{{ blacklistStats.total }}</span> IP 在黑名单中
<div class="text-sm text-muted-foreground">
当前共有 <span class="font-semibold text-foreground">{{ blacklistData.total || blacklistStats.total || 0 }}</span> IP 在黑名单中
</div>
<Table class="hidden sm:table">
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
<TableHead>原因</TableHead>
<TableHead>剩余时长</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in blacklistData.items"
:key="entry.ip_address"
>
<TableCell class="font-mono text-sm">
{{ entry.ip_address }}
</TableCell>
<TableCell class="max-w-[28rem] truncate">
{{ entry.reason }}
</TableCell>
<TableCell class="whitespace-nowrap">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
class="h-8 px-3"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4 mr-1.5" />
移除
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="entry in blacklistData.items"
:key="entry.ip_address"
class="p-4 flex items-start justify-between gap-3"
>
<div class="min-w-0 space-y-1">
<div class="font-mono text-sm break-all">
{{ entry.ip_address }}
</div>
<div class="text-xs text-muted-foreground leading-5 break-words">
{{ entry.reason }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</div>
</div>
<Button
variant="ghost"
size="sm"
class="h-8 px-3 shrink-0"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</Card>
@@ -213,15 +300,17 @@
<!-- 添加黑名单对话框 -->
<Dialog v-model:open="showAddBlacklistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到黑名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到黑名单
</DialogTitle>
<DialogDescription class="!mt-1">
被加入黑名单的 IP 将无法访问任何接口
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址</label>
<Input
v-model="blacklistForm.ip_address"
@@ -229,7 +318,7 @@
class="font-mono"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">原因</label>
<Input
v-model="blacklistForm.reason"
@@ -237,7 +326,7 @@
maxlength="200"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">过期时间可选</label>
<Input
v-model.number="blacklistForm.ttl"
@@ -250,7 +339,7 @@
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddBlacklistDialog = false"
@@ -270,27 +359,29 @@
<!-- 添加白名单对话框 -->
<Dialog v-model:open="showAddWhitelistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到白名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到白名单
</DialogTitle>
<DialogDescription class="!mt-1">
白名单中的 IP 不受速率限制
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址或 CIDR</label>
<Input
v-model="whitelistForm.ip_address"
placeholder="例如: 192.168.1.0/24 或 192.168.1.100"
class="font-mono"
/>
<p class="text-xs text-muted-foreground">
<p class="text-xs text-muted-foreground leading-5">
支持单个 IP CIDR 网段格式
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddWhitelistDialog = false"
@@ -330,7 +421,7 @@ import {
TableRow,
RefreshButton
} from '@/components/ui'
import { blacklistApi, whitelistApi, type BlacklistStats, type WhitelistResponse } from '@/api/security'
import { blacklistApi, whitelistApi, type BlacklistStats, type BlacklistResponse, type WhitelistResponse } from '@/api/security'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
@@ -344,6 +435,11 @@ const blacklistStats = ref<BlacklistStats>({
available: false,
total: 0
})
const blacklistData = ref<BlacklistResponse>({
items: [],
total: 0
})
const blacklistListError = ref<string | null>(null)
const showAddBlacklistDialog = ref(false)
const blacklistForm = ref({
ip_address: '',
@@ -363,14 +459,38 @@ const whitelistForm = ref({
})
/**
* 加载黑名单统计
* 加载黑名单统计和列表
*/
async function loadBlacklistStats() {
async function loadBlacklist() {
loadingBlacklist.value = true
blacklistListError.value = null
try {
blacklistStats.value = await blacklistApi.getStats()
const [statsResult, listResult] = await Promise.allSettled([
blacklistApi.getStats(),
blacklistApi.getList()
])
if (statsResult.status === 'fulfilled') {
blacklistStats.value = statsResult.value
} else {
blacklistStats.value = {
available: false,
total: 0,
error: parseApiError(statsResult.reason, '无法获取黑名单统计')
}
}
if (listResult.status === 'fulfilled') {
blacklistData.value = listResult.value
} else {
blacklistData.value = {
items: [],
total: 0
}
blacklistListError.value = parseApiError(listResult.reason, '无法获取黑名单列表')
}
} catch (err: unknown) {
error(parseApiError(err, '无法获取黑名单统计'))
error(parseApiError(err, '无法获取黑名单数据'))
} finally {
loadingBlacklist.value = false
}
@@ -405,7 +525,7 @@ async function handleAddToBlacklist() {
showAddBlacklistDialog.value = false
blacklistForm.value = { ip_address: '', reason: '', ttl: undefined }
await loadBlacklistStats()
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法添加 IP 到黑名单'))
}
@@ -452,8 +572,46 @@ async function handleRemoveFromWhitelist(ip: string) {
}
}
/**
* 从黑名单移除 IP
*/
async function handleRemoveFromBlacklist(ip: string) {
const confirmed = await confirmDanger(
`确定要从黑名单移除 ${ip} 吗?\n\n此操作无法撤销。`,
'移除黑名单'
)
if (!confirmed) return
try {
await blacklistApi.remove(ip)
success(`IP ${ip} 已从黑名单移除`)
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法从黑名单移除 IP'))
}
}
function formatBlacklistTTL(ttlSeconds?: number | null) {
if (ttlSeconds == null) return '永久'
if (ttlSeconds <= 0) return '即将过期'
const days = Math.floor(ttlSeconds / 86400)
if (days > 0) return `${days}`
const hours = Math.floor(ttlSeconds / 3600)
if (hours > 0) return `${hours} 小时`
const minutes = Math.floor(ttlSeconds / 60)
if (minutes > 0) return `${minutes} 分钟`
return `${ttlSeconds}`
}
onMounted(() => {
loadBlacklistStats()
loadBlacklist()
loadWhitelist()
})
</script>

View File

@@ -80,52 +80,39 @@ import { Settings } from 'lucide-vue-next'
</section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12 pt-8 border-t border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]">
<!-- 6. 能力标签 -->
<section
id="capabilities"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
6. 能力标签
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
为特定的 Key 或模型添加自定义标签 Vision, Function Calling, Long Context通过标签约束路由只选择具备该能力的可用通道
</p>
</section>
<!-- 7. 余额监控 -->
<!-- 6. 余额监控 -->
<section
id="balance-monitor"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
7. 余额监控
6. 余额监控
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
针对各大提供商的官方接口或常见聚合平台自动抓取并记录剩余额度在余额低于阈值时触发报警或禁用策略
</p>
</section>
<!-- 8. 配置导入/ -->
<!-- 7. 配置导入/ -->
<section
id="config-export"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
8. 配置导入/
7. 配置导入/
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
支持将统一模型配置提供商端点及网关路由策略一键导出为 JSON并在其他部署实例中迁移导入
</p>
</section>
<!-- 9. 锁定用户密钥 -->
<!-- 8. 锁定用户密钥 -->
<section
id="lock-key"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
9. 锁定用户密钥
8. 锁定用户密钥
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
若监控发现恶意使用异常调用或高频报错管理员可以临时或永久锁定特定密钥以阻断攻击源头

View File

@@ -158,7 +158,6 @@ import { BookOpen } from 'lucide-vue-next'
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">熔断探测</strong><br>
当同一个提供商Key字连续若干次请求失败后会进入熔断状态之后每间N分钟进行探测请求若请求成功解除熔断后续正常请求否则按以指数级增长探测时间以待下次探测最大探测间隔不会增长超过32分钟
</li>
<li><strong class="text-[#262624] dark:text-[#f1ead8] font-medium">能力标签</strong>定义该Key可以使用的能力</li>
<li>
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">自动获取上游模型</strong><br>
在上游获取模型端点支持的情况下从接口自动获取可以用模型列表且按一定时间自动刷新不开启则默认任意模型可用或在后续模型权限中手动添加

View File

@@ -85,7 +85,6 @@ export const guideNavItems: GuideNavItem[] = [
{ name: '请求头/体编辑', hash: '#header-body-edit' },
{ name: '模型映射', hash: '#model-mapping' },
{ name: '正则映射', hash: '#regex-mapping' },
{ name: '能力标签', hash: '#capabilities' },
{ name: '余额监控', hash: '#balance-monitor' },
{ name: '配置导入/出', hash: '#config-export' },
{ name: '锁定用户密钥', hash: '#lock-key' }