Merge pull request #765 from SunSeekerX/feature_key_model_filter

feat(api-keys): 添加模型筛选功能
This commit is contained in:
Wesley Liddick
2025-12-06 06:00:32 -05:00
committed by GitHub
4 changed files with 220 additions and 17 deletions

View File

@@ -284,7 +284,8 @@ class RedisClient {
isActive = '', isActive = '',
sortBy = 'createdAt', sortBy = 'createdAt',
sortOrder = 'desc', sortOrder = 'desc',
excludeDeleted = true // 默认排除已删除的 API Keys excludeDeleted = true, // 默认排除已删除的 API Keys
modelFilter = []
} = options } = options
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞) // 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
@@ -332,6 +333,15 @@ class RedisClient {
} }
} }
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await this.getKeyIdsWithModels(
filteredKeys.map((k) => k.id),
modelFilter
)
filteredKeys = filteredKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 4. 排序 // 4. 排序
filteredKeys.sort((a, b) => { filteredKeys.sort((a, b) => {
// status 排序实际上使用 isActive 字段API Key 没有 status 字段) // status 排序实际上使用 isActive 字段API Key 没有 status 字段)
@@ -781,6 +791,58 @@ class RedisClient {
await Promise.all(operations) await Promise.all(operations)
} }
/**
* 获取使用了指定模型的 Key IDsOR 逻辑)
*/
async getKeyIdsWithModels(keyIds, models) {
if (!keyIds.length || !models.length) {
return new Set()
}
const client = this.getClientSafe()
const result = new Set()
// 批量检查每个 keyId 是否使用过任意一个指定模型
for (const keyId of keyIds) {
for (const model of models) {
// 检查是否有该模型的使用记录daily 或 monthly
const pattern = `usage:${keyId}:model:*:${model}:*`
const keys = await client.keys(pattern)
if (keys.length > 0) {
result.add(keyId)
break // 找到一个就够了OR 逻辑)
}
}
}
return result
}
/**
* 获取所有被使用过的模型列表
*/
async getAllUsedModels() {
const client = this.getClientSafe()
const models = new Set()
// 扫描所有模型使用记录
const pattern = 'usage:*:model:daily:*'
let cursor = '0'
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
cursor = nextCursor
for (const key of keys) {
// 从 key 中提取模型名: usage:{keyId}:model:daily:{model}:{date}
const match = key.match(/usage:[^:]+:model:daily:([^:]+):/)
if (match) {
models.add(match[1])
}
}
} while (cursor !== '0')
return [...models].sort()
}
async getUsageStats(keyId) { async getUsageStats(keyId) {
const totalKey = `usage:${keyId}` const totalKey = `usage:${keyId}`
const today = getDateStringInTimezone() const today = getDateStringInTimezone()

View File

@@ -103,6 +103,17 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
} }
}) })
// 获取所有被使用过的模型列表
router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => {
try {
const models = await redis.getAllUsedModels()
return res.json({ success: true, data: models })
} catch (error) {
logger.error('❌ Failed to get used models:', error)
return res.status(500).json({ error: 'Failed to get used models', message: error.message })
}
})
// 获取所有API Keys // 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => { router.get('/api-keys', authenticateAdmin, async (req, res) => {
try { try {
@@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 筛选参数 // 筛选参数
tag = '', tag = '',
isActive = '', isActive = '',
models = '', // 模型筛选(逗号分隔)
// 排序参数 // 排序参数
sortBy = 'createdAt', sortBy = 'createdAt',
sortOrder = 'desc', sortOrder = 'desc',
@@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
timeRange = 'all' timeRange = 'all'
} = req.query } = req.query
// 解析模型筛选参数
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
// 验证分页参数 // 验证分页参数
const pageNum = Math.max(1, parseInt(page) || 1) const pageNum = Math.max(1, parseInt(page) || 1)
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20 const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
@@ -217,7 +232,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search, search,
searchMode, searchMode,
tag, tag,
isActive isActive,
modelFilter
}) })
costSortStatus = { costSortStatus = {
@@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search, search,
searchMode, searchMode,
tag, tag,
isActive isActive,
modelFilter
}) })
costSortStatus.isRealTimeCalculation = false costSortStatus.isRealTimeCalculation = false
@@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
tag, tag,
isActive, isActive,
sortBy: validSortBy, sortBy: validSortBy,
sortOrder: validSortOrder sortOrder: validSortOrder,
modelFilter
}) })
} }
@@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
* 使用预计算索引进行费用排序的分页查询 * 使用预计算索引进行费用排序的分页查询
*/ */
async function getApiKeysSortedByCostPrecomputed(options) { async function getApiKeysSortedByCostPrecomputed(options) {
const { page, pageSize, sortOrder, costTimeRange, search, searchMode, tag, isActive } = options const {
page,
pageSize,
sortOrder,
costTimeRange,
search,
searchMode,
tag,
isActive,
modelFilter = []
} = options
const costRankService = require('../../services/costRankService') const costRankService = require('../../services/costRankService')
// 1. 获取排序后的全量 keyId 列表 // 1. 获取排序后的全量 keyId 列表
@@ -369,6 +397,15 @@ async function getApiKeysSortedByCostPrecomputed(options) {
} }
} }
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await redis.getKeyIdsWithModels(
orderedKeys.map((k) => k.id),
modelFilter
)
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 5. 收集所有可用标签 // 5. 收集所有可用标签
const allTags = new Set() const allTags = new Set()
for (const key of allKeys) { for (const key of allKeys) {
@@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) {
* 使用实时计算进行 custom 时间范围的费用排序 * 使用实时计算进行 custom 时间范围的费用排序
*/ */
async function getApiKeysSortedByCostCustom(options) { async function getApiKeysSortedByCostCustom(options) {
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } = const {
options page,
pageSize,
sortOrder,
startDate,
endDate,
search,
searchMode,
tag,
isActive,
modelFilter = []
} = options
const costRankService = require('../../services/costRankService') const costRankService = require('../../services/costRankService')
// 1. 实时计算所有 Keys 的费用 // 1. 实时计算所有 Keys 的费用
@@ -427,9 +474,9 @@ async function getApiKeysSortedByCostCustom(options) {
} }
// 2. 转换为数组并排序 // 2. 转换为数组并排序
const sortedEntries = [...costs.entries()].sort((a, b) => { const sortedEntries = [...costs.entries()].sort((a, b) =>
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1] sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
}) )
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId) const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
// 3. 批量获取 API Key 基础数据 // 3. 批量获取 API Key 基础数据
@@ -465,6 +512,15 @@ async function getApiKeysSortedByCostCustom(options) {
} }
} }
// 模型筛选
if (modelFilter.length > 0) {
const keyIdsWithModels = await redis.getKeyIdsWithModels(
orderedKeys.map((k) => k.id),
modelFilter
)
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
}
// 6. 收集所有可用标签 // 6. 收集所有可用标签
const allTags = new Set() const allTags = new Set()
for (const key of allKeys) { for (const key of allKeys) {

View File

@@ -43,7 +43,7 @@
:key="option.value" :key="option.value"
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150" class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
:class="[ :class="[
option.value === modelValue isSelected(option.value)
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' ? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: option.isGroup : option.isGroup
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200' ? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
@@ -58,7 +58,7 @@
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i> <i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
<i <i
v-if="option.value === modelValue" v-if="isSelected(option.value)"
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400" class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
></i> ></i>
</div> </div>
@@ -74,7 +74,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: [String, Number], type: [String, Number, Array],
default: '' default: ''
}, },
options: { options: {
@@ -92,6 +92,10 @@ const props = defineProps({
iconColor: { iconColor: {
type: String, type: String,
default: 'text-gray-500' default: 'text-gray-500'
},
multiple: {
type: Boolean,
default: false
} }
}) })
@@ -102,7 +106,18 @@ const triggerRef = ref(null)
const dropdownRef = ref(null) const dropdownRef = ref(null)
const dropdownStyle = ref({}) const dropdownStyle = ref({})
const isSelected = (value) => {
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
}
return props.modelValue === value
}
const selectedLabel = computed(() => { const selectedLabel = computed(() => {
if (props.multiple) {
const count = Array.isArray(props.modelValue) ? props.modelValue.length : 0
return count > 0 ? `已选 ${count}` : ''
}
const selected = props.options.find((opt) => opt.value === props.modelValue) const selected = props.options.find((opt) => opt.value === props.modelValue)
return selected ? selected.label : '' return selected ? selected.label : ''
}) })
@@ -120,9 +135,21 @@ const closeDropdown = () => {
} }
const selectOption = (option) => { const selectOption = (option) => {
emit('update:modelValue', option.value) if (props.multiple) {
emit('change', option.value) const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
closeDropdown() const idx = current.indexOf(option.value)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(option.value)
}
emit('update:modelValue', current)
emit('change', current)
} else {
emit('update:modelValue', option.value)
emit('change', option.value)
closeDropdown()
}
} }
const updateDropdownPosition = () => { const updateDropdownPosition = () => {

View File

@@ -116,6 +116,29 @@
</div> </div>
</div> </div>
<!-- 模型筛选器 -->
<div class="group relative min-w-[140px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<div class="relative">
<CustomDropdown
v-model="selectedModels"
icon="fa-cube"
icon-color="text-orange-500"
:multiple="true"
:options="modelOptions"
placeholder="所有模型"
/>
<span
v-if="selectedModels.length > 0"
class="absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-xs text-white shadow-sm"
>
{{ selectedModels.length }}
</span>
</div>
</div>
<!-- 搜索模式与搜索框 --> <!-- 搜索模式与搜索框 -->
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center"> <div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
<div class="sm:w-44"> <div class="sm:w-44">
@@ -2220,6 +2243,10 @@ const selectedApiKeyForDetail = ref(null)
const selectedTagFilter = ref('') const selectedTagFilter = ref('')
const availableTags = ref([]) const availableTags = ref([])
// 模型筛选相关
const selectedModels = ref([])
const availableModels = ref([])
// 搜索相关 // 搜索相关
const searchKeyword = ref('') const searchKeyword = ref('')
const searchMode = ref('apiKey') const searchMode = ref('apiKey')
@@ -2236,6 +2263,14 @@ const tagOptions = computed(() => {
return options return options
}) })
const modelOptions = computed(() => {
return availableModels.value.map((model) => ({
value: model,
label: model,
icon: 'fa-cube'
}))
})
const selectedTagCount = computed(() => { const selectedTagCount = computed(() => {
if (!selectedTagFilter.value) return 0 if (!selectedTagFilter.value) return 0
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value)) return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
@@ -2474,6 +2509,18 @@ const loadAccounts = async (forceRefresh = false) => {
} }
} }
// 加载已使用的模型列表
const loadUsedModels = async () => {
try {
const data = await apiClient.get('/admin/api-keys/used-models')
if (data.success) {
availableModels.value = data.data || []
}
} catch (error) {
console.error('Failed to load used models:', error)
}
}
// 加载API Keys使用后端分页 // 加载API Keys使用后端分页
const loadApiKeys = async (clearStatsCache = true) => { const loadApiKeys = async (clearStatsCache = true) => {
apiKeysLoading.value = true apiKeysLoading.value = true
@@ -2502,6 +2549,11 @@ const loadApiKeys = async (clearStatsCache = true) => {
params.set('tag', selectedTagFilter.value) params.set('tag', selectedTagFilter.value)
} }
// 模型筛选参数
if (selectedModels.value.length > 0) {
params.set('models', selectedModels.value.join(','))
}
// 排序参数(支持费用排序) // 排序参数(支持费用排序)
const validSortFields = [ const validSortFields = [
'name', 'name',
@@ -4711,6 +4763,12 @@ watch(selectedTagFilter, () => {
loadApiKeys(false) loadApiKeys(false)
}) })
// 监听模型筛选变化
watch(selectedModels, () => {
currentPage.value = 1
loadApiKeys(false)
})
// 监听排序变化,重新加载数据 // 监听排序变化,重新加载数据
watch([apiKeysSortBy, apiKeysSortOrder], () => { watch([apiKeysSortBy, apiKeysSortOrder], () => {
loadApiKeys(false) loadApiKeys(false)
@@ -4745,7 +4803,7 @@ onMounted(async () => {
fetchCostSortStatus() fetchCostSortStatus()
// 先加载 API Keys优先显示列表 // 先加载 API Keys优先显示列表
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()]) await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys(), loadUsedModels()])
// 初始化全选状态 // 初始化全选状态
updateSelectAllState() updateSelectAllState()