diff --git a/src/models/redis.js b/src/models/redis.js index 10f10e7a..d0356ed8 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -284,7 +284,8 @@ class RedisClient { isActive = '', sortBy = 'createdAt', sortOrder = 'desc', - excludeDeleted = true // 默认排除已删除的 API Keys + excludeDeleted = true, // 默认排除已删除的 API Keys + modelFilter = [] } = options // 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. 排序 filteredKeys.sort((a, b) => { // status 排序实际上使用 isActive 字段(API Key 没有 status 字段) @@ -781,6 +791,56 @@ class RedisClient { await Promise.all(operations) } + /** + * 获取使用了指定模型的 Key IDs(OR 逻辑) + */ + 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) { const totalKey = `usage:${keyId}` const today = getDateStringInTimezone() diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index 3a45feb0..f9e38798 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -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 router.get('/api-keys', authenticateAdmin, async (req, res) => { try { @@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { // 筛选参数 tag = '', isActive = '', + models = '', // 模型筛选(逗号分隔) // 排序参数 sortBy = 'createdAt', sortOrder = 'desc', @@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { timeRange = 'all' } = req.query + // 解析模型筛选参数 + const modelFilter = models ? models.split(',').filter((m) => m.trim()) : [] + // 验证分页参数 const pageNum = Math.max(1, parseInt(page) || 1) 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, searchMode, tag, - isActive + isActive, + modelFilter }) costSortStatus = { @@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { search, searchMode, tag, - isActive + isActive, + modelFilter }) costSortStatus.isRealTimeCalculation = false @@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { tag, isActive, sortBy: validSortBy, - sortOrder: validSortOrder + sortOrder: validSortOrder, + modelFilter }) } @@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { * 使用预计算索引进行费用排序的分页查询 */ 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') // 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. 收集所有可用标签 const allTags = new Set() for (const key of allKeys) { @@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) { * 使用实时计算进行 custom 时间范围的费用排序 */ async function getApiKeysSortedByCostCustom(options) { - const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } = - options + const { + page, + pageSize, + sortOrder, + startDate, + endDate, + search, + searchMode, + tag, + isActive, + modelFilter = [] + } = options const costRankService = require('../../services/costRankService') // 1. 实时计算所有 Keys 的费用 @@ -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. 收集所有可用标签 const allTags = new Set() for (const key of allKeys) { diff --git a/web/admin-spa/src/components/common/CustomDropdown.vue b/web/admin-spa/src/components/common/CustomDropdown.vue index 16ae391d..f1c7f3bb 100644 --- a/web/admin-spa/src/components/common/CustomDropdown.vue +++ b/web/admin-spa/src/components/common/CustomDropdown.vue @@ -43,7 +43,7 @@ :key="option.value" class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150" :class="[ - option.value === modelValue + isSelected(option.value) ? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700' ]" @@ -52,7 +52,7 @@ {{ option.label }} @@ -68,7 +68,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' const props = defineProps({ modelValue: { - type: [String, Number], + type: [String, Number, Array], default: '' }, options: { @@ -86,6 +86,10 @@ const props = defineProps({ iconColor: { type: String, default: 'text-gray-500' + }, + multiple: { + type: Boolean, + default: false } }) @@ -96,7 +100,18 @@ const triggerRef = ref(null) const dropdownRef = ref(null) 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(() => { + 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) return selected ? selected.label : '' }) @@ -114,9 +129,21 @@ const closeDropdown = () => { } const selectOption = (option) => { - emit('update:modelValue', option.value) - emit('change', option.value) - closeDropdown() + if (props.multiple) { + const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] + 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 = () => { diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index a5c082cb..af15c7b2 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -116,6 +116,29 @@ + +
+
+
+ + + {{ selectedModels.length }} + +
+
+
@@ -2220,6 +2243,10 @@ const selectedApiKeyForDetail = ref(null) const selectedTagFilter = ref('') const availableTags = ref([]) +// 模型筛选相关 +const selectedModels = ref([]) +const availableModels = ref([]) + // 搜索相关 const searchKeyword = ref('') const searchMode = ref('apiKey') @@ -2236,6 +2263,14 @@ const tagOptions = computed(() => { return options }) +const modelOptions = computed(() => { + return availableModels.value.map((model) => ({ + value: model, + label: model, + icon: 'fa-cube' + })) +}) + const selectedTagCount = computed(() => { if (!selectedTagFilter.value) return 0 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(使用后端分页) const loadApiKeys = async (clearStatsCache = true) => { apiKeysLoading.value = true @@ -2502,6 +2549,11 @@ const loadApiKeys = async (clearStatsCache = true) => { params.set('tag', selectedTagFilter.value) } + // 模型筛选参数 + if (selectedModels.value.length > 0) { + params.set('models', selectedModels.value.join(',')) + } + // 排序参数(支持费用排序) const validSortFields = [ 'name', @@ -4711,6 +4763,12 @@ watch(selectedTagFilter, () => { loadApiKeys(false) }) +// 监听模型筛选变化 +watch(selectedModels, () => { + currentPage.value = 1 + loadApiKeys(false) +}) + // 监听排序变化,重新加载数据 watch([apiKeysSortBy, apiKeysSortOrder], () => { loadApiKeys(false) @@ -4745,7 +4803,7 @@ onMounted(async () => { fetchCostSortStatus() // 先加载 API Keys(优先显示列表) - await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()]) + await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys(), loadUsedModels()]) // 初始化全选状态 updateSelectAllState()