diff --git a/controller/token.go b/controller/token.go index 836e9b2952ac..ca305906b319 100644 --- a/controller/token.go +++ b/controller/token.go @@ -62,6 +62,23 @@ func SearchTokens(c *gin.Context) { common.ApiSuccess(c, pageInfo) } +// SearchAllTokens 管理员全局搜索所有用户的 API KEY。 +func SearchAllTokens(c *gin.Context) { + keyword := c.Query("keyword") + token := c.Query("token") + + pageInfo := common.GetPageQuery(c) + + tokens, total, err := model.SearchAllTokens(keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens)) + common.ApiSuccess(c, pageInfo) +} + func GetToken(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) userId := c.GetInt("id") diff --git a/controller/usedata.go b/controller/usedata.go index 52c6287dcee0..8eb532640989 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -32,7 +32,8 @@ func GetAllQuotaDates(c *gin.Context) { startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) username := c.Query("username") - dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username) + tokenID, _ := strconv.Atoi(c.Query("token_id")) + dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username, tokenID) if err != nil { common.ApiError(c, err) return @@ -64,6 +65,7 @@ func GetUserQuotaDates(c *gin.Context) { userId := c.GetInt("id") startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenID, _ := strconv.Atoi(c.Query("token_id")) // 判断时间跨度是否超过 1 个月 if endTimestamp-startTimestamp > 2592000 { c.JSON(http.StatusOK, gin.H{ @@ -72,7 +74,7 @@ func GetUserQuotaDates(c *gin.Context) { }) return } - dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp) + dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp, tokenID) if err != nil { common.ApiError(c, err) return diff --git a/controller/usedata_test.go b/controller/usedata_test.go new file mode 100644 index 000000000000..b9571ffd961e --- /dev/null +++ b/controller/usedata_test.go @@ -0,0 +1,87 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type quotaDatesResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []model.QuotaData `json:"data"` +} + +func decodeQuotaDatesResponse(t *testing.T, recorder *httptest.ResponseRecorder) quotaDatesResponse { + t.Helper() + require.Equal(t, http.StatusOK, recorder.Code) + var payload quotaDatesResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload)) + require.True(t, payload.Success, payload.Message) + return payload +} + +func TestGetAllQuotaDatesFiltersByTokenID(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data?start_timestamp=1000&end_timestamp=2000&token_id=11", nil) + + GetAllQuotaDates(ctx) + + payload := decodeQuotaDatesResponse(t, recorder) + require.Len(t, payload.Data, 1) + require.Equal(t, "gpt-a", payload.Data[0].ModelName) + require.Equal(t, 2, payload.Data[0].Count) + require.Equal(t, 100, payload.Data[0].Quota) +} + +func TestGetAllQuotaDatesIgnoresZeroTokenID(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data?start_timestamp=1000&end_timestamp=2000&token_id=0", nil) + + GetAllQuotaDates(ctx) + + payload := decodeQuotaDatesResponse(t, recorder) + require.Len(t, payload.Data, 2) +} + +func TestGetUserQuotaDatesFiltersByTokenID(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("id", 1) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/self?start_timestamp=1000&end_timestamp=2000&token_id=11", nil) + + GetUserQuotaDates(ctx) + + payload := decodeQuotaDatesResponse(t, recorder) + require.Len(t, payload.Data, 1) + require.Equal(t, "gpt-a", payload.Data[0].ModelName) + require.Equal(t, "alice", payload.Data[0].Username) +} + +func TestGetUserQuotaDatesIgnoresOtherUserTokenID(t *testing.T) { + setupFlowControllerTestDB(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("id", 1) + // token_id=22 属于 user 2,当前用户是 user 1,因此应返回空 + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/data/self?start_timestamp=1000&end_timestamp=2000&token_id=22", nil) + + GetUserQuotaDates(ctx) + + payload := decodeQuotaDatesResponse(t, recorder) + require.Empty(t, payload.Data) +} diff --git a/model/token.go b/model/token.go index 5d62258e7920..51df61650b4e 100644 --- a/model/token.go +++ b/model/token.go @@ -118,14 +118,6 @@ func validateLikePattern(input string) error { return errors.New("搜索模式中最多允许包含 2 个 % 通配符") } - // 3. 含 % 时,去掉 % 后关键词长度必须 >= 2 - if count > 0 { - stripped := strings.ReplaceAll(input, "%", "") - if len(stripped) < 2 { - return errors.New("使用模糊搜索时,关键词长度至少为 2 个字符") - } - } - return nil } @@ -160,20 +152,25 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId) - // 非空才加 LIKE 条件,空则跳过(不过滤该字段) + // 非空才加 LIKE 条件,空则跳过(不过滤该字段)。 + // 若用户未显式输入通配符 %,默认按前缀模糊匹配(例如 RD11 匹配 RD1141)。 + // 使用 LOWER() 实现跨数据库的大小写不敏感搜索。 if keyword != "" { + if !strings.Contains(keyword, "%") { + keyword = keyword + "%" + } keywordPattern, err := sanitizeLikePattern(keyword) if err != nil { return nil, 0, err } - baseQuery = baseQuery.Where("name LIKE ? ESCAPE '!'", keywordPattern) + baseQuery = baseQuery.Where("LOWER(name) LIKE LOWER(?) ESCAPE '!'", keywordPattern) } if token != "" { tokenPattern, err := sanitizeLikePattern(token) if err != nil { return nil, 0, err } - baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern) + baseQuery = baseQuery.Where("LOWER("+commonKeyCol+") LIKE LOWER(?) ESCAPE '!'", tokenPattern) } // 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT) @@ -192,6 +189,66 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi return tokens, total, nil } +// SearchAllTokens 全局搜索所有用户的 API KEY(管理员专用)。 +// 实现复用 SearchUserTokens 的 LIKE 转义与截断逻辑,但不做 user_id 限制。 +func SearchAllTokens(keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) { + // model 层强制截断 + if limit <= 0 || limit > searchHardLimit { + limit = searchHardLimit + } + if offset < 0 { + offset = 0 + } + + if token != "" { + token = strings.TrimPrefix(token, "sk-") + } + + // 与 SearchUserTokens 一致:用 maxTokens 封顶 COUNT,避免 admin 全局搜索时 + // 在大表上做全表 COUNT 扫描。total 实际为 min(真实匹配数, maxTokens),配合硬上限分页足够。 + maxTokens := operation_setting.GetMaxUserTokens() + + baseQuery := DB.Model(&Token{}) + + // 非空才加 LIKE 条件,空则跳过(不过滤该字段)。 + // 若用户未显式输入通配符 %,默认按前缀模糊匹配(例如 RD11 匹配 RD1141)。 + // 使用 LOWER() 实现跨数据库的大小写不敏感搜索。 + if keyword != "" { + if !strings.Contains(keyword, "%") { + keyword = keyword + "%" + } + keywordPattern, err := sanitizeLikePattern(keyword) + if err != nil { + return nil, 0, err + } + baseQuery = baseQuery.Where("LOWER(name) LIKE LOWER(?) ESCAPE '!'", keywordPattern) + } + if token != "" { + tokenPattern, err := sanitizeLikePattern(token) + if err != nil { + return nil, 0, err + } + baseQuery = baseQuery.Where("LOWER("+commonKeyCol+") LIKE LOWER(?) ESCAPE '!'", tokenPattern) + } + + // 先查匹配总数 + // 与 SearchUserTokens 保持一致:用 maxTokens 封顶 COUNT,避免 admin 全局搜索时 + // 在大表上做全表 COUNT 扫描。total 实际为 min(真实匹配数, maxTokens),配合硬上限分页足够。 + err = baseQuery.Limit(maxTokens).Count(&total).Error + if err != nil { + common.SysError("failed to count search all tokens: " + err.Error()) + return nil, 0, errors.New("搜索令牌失败") + } + + // 再分页查数据 + err = baseQuery.Order("id desc").Offset(offset).Limit(limit).Find(&tokens).Error + if err != nil { + common.SysError("failed to search all tokens: " + err.Error()) + return nil, 0, errors.New("搜索令牌失败") + } + return tokens, total, nil +} + func ValidateUserToken(key string) (token *Token, err error) { if key == "" { return nil, ErrTokenNotProvided diff --git a/model/usedata.go b/model/usedata.go index 4190235bc2d9..430acb032e68 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -138,24 +138,32 @@ func increaseQuotaData(quotaData *QuotaData) { } } -func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { +// GetQuotaDataByUsername 根据用户名查询配额数据;传入 tokenId > 0 时进一步按 API KEY 过滤。 +func GetQuotaDataByUsername(username string, startTime int64, endTime int64, tokenId int) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data"). + query := DB.Table("quota_data"). Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). - Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime). - Group("user_id, username, model_name, created_at"). + Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime) + if tokenId > 0 { + query = query.Where("token_id = ?", tokenId) + } + err = query.Group("user_id, username, model_name, created_at"). Find("aDatas).Error return quotaDatas, err } -func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { +// GetQuotaDataByUserId 根据用户 ID 查询配额数据;传入 tokenId > 0 时进一步按 API KEY 过滤。 +func GetQuotaDataByUserId(userId int, startTime int64, endTime int64, tokenId int) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data"). + query := DB.Table("quota_data"). Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). - Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime). - Group("user_id, username, model_name, created_at"). + Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime) + if tokenId > 0 { + query = query.Where("token_id = ?", tokenId) + } + err = query.Group("user_id, username, model_name, created_at"). Find("aDatas).Error return quotaDatas, err } @@ -170,14 +178,19 @@ func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*Quota return quotaDatas, err } -func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) { +// GetAllQuotaDates 查询全部配额数据;传入 username 时按用户过滤,传入 tokenId > 0 时按 API KEY 过滤。 +func GetAllQuotaDates(startTime int64, endTime int64, username string, tokenId int) (quotaData []*QuotaData, err error) { if username != "" { - return GetQuotaDataByUsername(username, startTime, endTime) + return GetQuotaDataByUsername(username, startTime, endTime, tokenId) } var quotaDatas []*QuotaData // 从quota_data表中查询数据 - // only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at; - //err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find("aDatas).Error - err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error + query := DB.Table("quota_data"). + Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at"). + Where("created_at >= ? and created_at <= ?", startTime, endTime) + if tokenId > 0 { + query = query.Where("token_id = ?", tokenId) + } + err = query.Group("model_name, created_at").Find("aDatas).Error return quotaDatas, err } diff --git a/model/usedata_test.go b/model/usedata_test.go new file mode 100644 index 000000000000..799df08be790 --- /dev/null +++ b/model/usedata_test.go @@ -0,0 +1,75 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func setupUsedataTestDB(t *testing.T) { + t.Helper() + truncateTables(t) + require.NoError(t, DB.Create(&Token{Id: 11, UserId: 1, Key: "sk-primary", Name: "primary"}).Error) + require.NoError(t, DB.Create(&Token{Id: 22, UserId: 2, Key: "sk-backup", Name: "backup"}).Error) + require.NoError(t, DB.Create(&QuotaData{ + UserID: 1, + Username: "alice", + TokenID: 11, + ModelName: "gpt-a", + CreatedAt: 1100, + Count: 2, + Quota: 100, + TokenUsed: 40, + }).Error) + require.NoError(t, DB.Create(&QuotaData{ + UserID: 2, + Username: "bob", + TokenID: 22, + ModelName: "gpt-b", + CreatedAt: 1200, + Count: 1, + Quota: 70, + TokenUsed: 30, + }).Error) +} + +func TestGetAllQuotaDatesByTokenID(t *testing.T) { + setupUsedataTestDB(t) + + rows, err := GetAllQuotaDates(1000, 2000, "", 11) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "gpt-a", rows[0].ModelName) + require.Equal(t, 2, rows[0].Count) + + rows, err = GetAllQuotaDates(1000, 2000, "", 0) + require.NoError(t, err) + require.Len(t, rows, 2) +} + +func TestGetQuotaDataByUserIdWithTokenID(t *testing.T) { + setupUsedataTestDB(t) + + rows, err := GetQuotaDataByUserId(1, 1000, 2000, 11) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "alice", rows[0].Username) + require.Equal(t, "gpt-a", rows[0].ModelName) + + rows, err = GetQuotaDataByUserId(1, 1000, 2000, 22) + require.NoError(t, err) + require.Empty(t, rows) +} + +func TestGetQuotaDataByUsernameWithTokenID(t *testing.T) { + setupUsedataTestDB(t) + + rows, err := GetQuotaDataByUsername("alice", 1000, 2000, 11) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "gpt-a", rows[0].ModelName) + + rows, err = GetQuotaDataByUsername("alice", 1000, 2000, 22) + require.NoError(t, err) + require.Empty(t, rows) +} diff --git a/router/api-router.go b/router/api-router.go index 80fd65178c44..c7ea9fd5cb8b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -245,6 +245,13 @@ func SetApiRouter(router *gin.Engine) { tokenRoute.DELETE("/:id", controller.DeleteToken) tokenRoute.POST("/batch", controller.DeleteTokenBatch) tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch) + + // 管理员全局搜索所有用户的 API KEY,供数据看板等场景使用 + adminTokenRoute := tokenRoute.Group("/admin") + adminTokenRoute.Use(middleware.AdminAuth()) + { + adminTokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchAllTokens) + } } usageRoute := apiRouter.Group("/usage") diff --git a/web/src/features/dashboard/api.ts b/web/src/features/dashboard/api.ts index 8429da854ab3..9ab36ed0d60f 100644 --- a/web/src/features/dashboard/api.ts +++ b/web/src/features/dashboard/api.ts @@ -40,6 +40,7 @@ export async function getUserQuotaDates( end_timestamp: number default_time?: string username?: string + token_id?: number }, isAdmin = false ) { diff --git a/web/src/features/dashboard/components/models/models-filter-dialog.tsx b/web/src/features/dashboard/components/models/models-filter-dialog.tsx index 44cd63098662..9f8e28311d05 100644 --- a/web/src/features/dashboard/components/models/models-filter-dialog.tsx +++ b/web/src/features/dashboard/components/models/models-filter-dialog.tsx @@ -17,12 +17,21 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { Filter, RotateCcw, Calendar, Search } from 'lucide-react' -import { useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useQuery } from '@tanstack/react-query' import { DateTimePicker } from '@/components/datetime-picker' import { Dialog } from '@/components/dialog' import { Button } from '@/components/ui/button' +import { useDebounce } from '@/hooks' +import { + Combobox, + ComboboxContent, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' @@ -46,6 +55,8 @@ import type { DashboardChartPreferences, DashboardFilters, } from '@/features/dashboard/types' +import { searchAdminApiKeys, searchApiKeys, getApiKey } from '@/features/keys/api' +import type { ApiKey } from '@/features/keys/types' import { getRollingDateRange, type TimeGranularity } from '@/lib/time' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -96,6 +107,165 @@ const SectionDivider = ({ label }: { label: string }) => ( ) +interface TokenFilterComboboxProps { + value?: number + onValueChange: (value?: number) => void + isAdmin: boolean +} + +// 可搜索单选 API KEY 选择器;管理员可查看全部 key,普通用户只能查看自己的 key。 +// 使用 Base UI Combobox,搜索框固定不动,输入即触发后端搜索,大小写不敏感。 +function TokenFilterCombobox({ + value, + onValueChange, + isAdmin, +}: TokenFilterComboboxProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [keyword, setKeyword] = useState('') + // 搜索词防抖,避免每输入一个字符就触发一次后端请求。 + const debouncedKeyword = useDebounce(keyword, 300) + const [inputValue, setInputValue] = useState('') + // 缓存所有加载过的 token id -> name,避免选择后因当前 options 被过滤而找不到 label。 + const [tokenMap, setTokenMap] = useState>({}) + + const { data: tokenOptions } = useQuery({ + queryKey: [ + 'dashboard', + 'token-options', + isAdmin ? 'admin' : 'self', + debouncedKeyword, + ], + queryFn: async () => { + const res = isAdmin + ? await searchAdminApiKeys({ keyword: debouncedKeyword, size: 100 }) + : await searchApiKeys({ keyword: debouncedKeyword, size: 100 }) + return res.success ? (res.data?.items ?? []) : [] + }, + enabled: open, + staleTime: 60_000, + }) + + // 预选中的 key 若不在已加载的搜索结果中(例如来自 URL/持久化筛选且 token 较旧), + // 单独按 id 取一次名称,避免输入框错误地回退显示“全部 API 密钥”。 + const { data: preselectedToken } = useQuery({ + queryKey: ['dashboard', 'token-selected', value], + queryFn: async () => { + if (!value) return null + const res = await getApiKey(value) + return res.success ? (res.data ?? null) : null + }, + enabled: !!value && !tokenMap[String(value)], + staleTime: 60_000, + }) + + // 把每次后端返回的 token 更新到缓存中,确保已选项名称始终可解析。 + useEffect(() => { + const incoming = [...(tokenOptions ?? [])] + if (preselectedToken) incoming.push(preselectedToken) + if (incoming.length === 0) return + setTokenMap((prev) => { + const next = { ...prev } + for (const token of incoming) { + next[String(token.id)] = token.name + } + return next + }) + }, [tokenOptions, preselectedToken]) + + const options = useMemo(() => { + const allOption = { value: '__all__', label: t('All API keys') } + return [ + allOption, + ...(tokenOptions ?? []).map((token) => ({ + value: String(token.id), + label: token.name, + })), + ] + }, [tokenOptions, t]) + + const items = useMemo(() => options.map((option) => option.value), [options]) + const selectedValue = value ? String(value) : '__all__' + const selectedLabel = + selectedValue === '__all__' + ? t('All API keys') + : (tokenMap[selectedValue] ?? '') + + // 下拉框关闭或选中项变化时,输入框恢复为当前选中项名称,便于用户看清已选内容。 + useEffect(() => { + if (!open) { + setInputValue(selectedLabel || t('All API keys')) + } + }, [open, selectedLabel, t]) + + // Base UI 在选择/关闭时可能尝试把 value 转回输入框文本; + // 用 tokenMap 而不是当前 options 解析,防止回退显示数据库 id。 + const itemToStringLabel = useCallback( + (itemValue: string) => { + if (itemValue === '__all__') return t('All API keys') + return tokenMap[itemValue] ?? itemValue + }, + [tokenMap, t] + ) + + const handleValueChange = (nextValue: string | null) => { + if (nextValue === '__all__' || nextValue === null) { + onValueChange(undefined) + setInputValue(t('All API keys')) + } else { + onValueChange(Number(nextValue)) + // 选择后立即写入名称,防止 Base UI 用 value(id)回填输入框。 + const label = tokenMap[nextValue] ?? nextValue + setInputValue(label) + } + setOpen(false) + } + + const handleInputValueChange = (nextInputValue: string) => { + setInputValue(nextInputValue) + setKeyword(nextInputValue) + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + // 打开下拉框时清空输入框,方便用户立即输入搜索词。 + setInputValue('') + setKeyword('') + } + } + + return ( + true} + > + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + ) +} + export function ModelsFilter(props: ModelsFilterProps) { const { t } = useTranslation() // 使用已缓存的用户数据,避免重复调用 API @@ -147,11 +317,12 @@ export function ModelsFilter(props: ModelsFilterProps) { const handleChange = ( field: keyof DashboardFilters, - value: Date | string | undefined + value: Date | string | number | undefined ) => { setFilters((prev) => ({ ...prev, [field]: value })) - if (field === 'start_timestamp' || field === 'end_timestamp') + if (field === 'start_timestamp' || field === 'end_timestamp') { setSelectedRange(null) + } } const handleQuickRange = (days: number) => { @@ -179,7 +350,7 @@ export function ModelsFilter(props: ModelsFilterProps) { title={t(props.titleKey ?? 'Model Analytics Filters')} description={t( props.descriptionKey ?? - 'Filter the model analytics view by time range and user.' + 'Filter the model analytics view by time range, user and API key.' )} contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg' contentHeight='min(48vh, 460px)' @@ -257,12 +428,10 @@ export function ModelsFilter(props: ModelsFilterProps) {
+ + +
+ + handleChange('token_id', value)} + isAdmin={Boolean(isAdmin)} + /> +
+ {/* Admin-only fields */} {isAdmin && ( <> diff --git a/web/src/features/dashboard/constants.ts b/web/src/features/dashboard/constants.ts index 34e6828e7678..b3c2dfc93491 100644 --- a/web/src/features/dashboard/constants.ts +++ b/web/src/features/dashboard/constants.ts @@ -66,4 +66,5 @@ export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = { end_timestamp: undefined, time_granularity: 'hour', username: '', + token_id: undefined, } diff --git a/web/src/features/dashboard/lib/filters.ts b/web/src/features/dashboard/lib/filters.ts index 321f608d995c..9613497846e0 100644 --- a/web/src/features/dashboard/lib/filters.ts +++ b/web/src/features/dashboard/lib/filters.ts @@ -154,16 +154,18 @@ export function buildDefaultDashboardFilters( export function buildQueryParams( timeRange: { start_timestamp: number; end_timestamp: number }, - filters?: { time_granularity?: TimeGranularity; username?: string } + filters?: { time_granularity?: TimeGranularity; username?: string; token_id?: number } ): { start_timestamp: number end_timestamp: number default_time: string username?: string + token_id?: number } { return { ...timeRange, default_time: getSavedGranularity(filters?.time_granularity), ...(filters?.username && { username: filters.username }), + ...(filters?.token_id && filters.token_id > 0 && { token_id: filters.token_id }), } } diff --git a/web/src/features/dashboard/types.ts b/web/src/features/dashboard/types.ts index b8771df2565a..8587f6e3d010 100644 --- a/web/src/features/dashboard/types.ts +++ b/web/src/features/dashboard/types.ts @@ -189,6 +189,7 @@ export interface DashboardFilters { end_timestamp?: Date time_granularity?: TimeGranularity username?: string + token_id?: number } export type ConsumptionDistributionChartType = 'bar' | 'area' diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts index df3cc5ff74bc..e5e3ec2beffa 100644 --- a/web/src/features/keys/api.ts +++ b/web/src/features/keys/api.ts @@ -54,6 +54,20 @@ export async function searchApiKeys( return res.data } +// Admin-only: search all users' API keys by keyword or token (with pagination) +export async function searchAdminApiKeys( + params: SearchApiKeysParams +): Promise { + const { keyword = '', token = '', p, size } = params + const queryParams = new URLSearchParams() + if (keyword) queryParams.set('keyword', keyword) + if (token) queryParams.set('token', token) + if (p != null) queryParams.set('p', String(p)) + if (size != null) queryParams.set('size', String(size)) + const res = await api.get(`/api/token/admin/search?${queryParams.toString()}`) + return res.data +} + // Get single API key by ID export async function getApiKey(id: number): Promise> { const res = await api.get(`/api/token/${id}`) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 609e72eb08c8..1f0af95a8e6d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -279,6 +279,7 @@ "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "All", + "All API keys": "All API keys", "All API tokens": "All API tokens", "All categories": "All categories", "All conditions must match before this tier is used.": "All conditions must match before this tier is used.", @@ -381,6 +382,7 @@ "API info updated. Click \"Save Settings\" to apply.": "API info updated. Click \"Save Settings\" to apply.", "API key": "API key", "API Key": "API Key", + "API Key Filter": "API Key Filter", "API Key (one per line for batch mode)": "API Key (one per line for batch mode)", "API Key (Production)": "API Key (Production)", "API Key (Sandbox)": "API Key (Sandbox)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "Filter models by provider, group, type, endpoint, and tags.", "Filter models by type, endpoint, vendor, group and tags": "Filter models by type, endpoint, vendor, group and tags", "Filter models...": "Filter models...", - "Filter the model analytics view by time range and user.": "Filter the model analytics view by time range and user.", + "Filter the model analytics view by time range, user and API key.": "Filter the model analytics view by time range, user and API key.", "Filter the traffic flow view by time range and user.": "Filter the traffic flow view by time range and user.", "Filter...": "Filter...", "Filters": "Filters", @@ -2847,6 +2849,7 @@ "No apps match the selected filters": "No apps match the selected filters", "No Auth": "No Auth", "No available models": "No available models", + "No API key found.": "No API key found.", "No available Web chat links": "No available Web chat links", "No backup": "No backup", "No base input price": "No base input price", @@ -3984,6 +3987,7 @@ "Search": "Search", "Search by name or URL...": "Search by name or URL...", "Search by order number...": "Search by order number...", + "Search API keys...": "Search API keys...", "Search channel type...": "Search channel type...", "Search chat presets...": "Search chat presets...", "Search colors...": "Search colors...", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 443b3fa1b55f..5a5cdce5106e 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -279,6 +279,7 @@ "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "Tout", + "All API keys": "Toutes les clés API", "All API tokens": "Tous les jetons API", "All categories": "Toutes catégories", "All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.", @@ -381,6 +382,7 @@ "API info updated. Click \"Save Settings\" to apply.": "Informations API mises à jour. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.", "API key": "Clé API", "API Key": "Clé API", + "API Key Filter": "Filtre de clé API", "API Key (one per line for batch mode)": "Clé API (une par ligne pour le mode batch)", "API Key (Production)": "Clé API (Production)", "API Key (Sandbox)": "Clé API (Sandbox)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "Filtrer les modèles par fournisseur, groupe, type, endpoint et tags.", "Filter models by type, endpoint, vendor, group and tags": "Filtrer les modèles par type, point d'accès, fournisseur, groupe et tags", "Filter models...": "Filtrer les modèles...", - "Filter the model analytics view by time range and user.": "Filtrez la vue d’analyse des modèles par période et utilisateur.", + "Filter the model analytics view by time range, user and API key.": "Filtrez la vue d’analyse des modèles par période, utilisateur et clé API.", "Filter the traffic flow view by time range and user.": "Filtrez la vue du flux de trafic par plage horaire et utilisateur.", "Filter...": "Filtrer...", "Filters": "Filtres", @@ -2844,6 +2846,7 @@ "No API routes configured": "Aucune route API configurée", "No API tokens": "Aucun jeton API", "No app usage data available for this model.": "Aucune donnée d'utilisation d'application n'est disponible pour ce modèle.", + "No API key found.": "Aucune clé API trouvée.", "No apps match the selected filters": "Aucune application ne correspond aux filtres", "No Auth": "Sans auth", "No available models": "Aucun modèle disponible", @@ -3984,6 +3987,7 @@ "Search": "Rechercher", "Search by name or URL...": "Rechercher par nom ou URL...", "Search by order number...": "Rechercher par numéro de commande...", + "Search API keys...": "Rechercher des clés API...", "Search channel type...": "Rechercher un type de canal...", "Search chat presets...": "Rechercher des préréglages de chat...", "Search colors...": "Rechercher des couleurs...", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index c15de5534b82..49c45f64a3e3 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -279,6 +279,7 @@ "Ali": "アリババ百炼", "Alipay": "Alipay", "All": "すべて", + "All API keys": "すべての API キー", "All API tokens": "すべての API キー", "All categories": "すべてのカテゴリ", "All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。", @@ -381,6 +382,7 @@ "API info updated. Click \"Save Settings\" to apply.": "API情報が更新されました。「Save Settings」をクリックして適用してください。", "API key": "APIキー", "API Key": "APIキー", + "API Key Filter": "API キー フィルター", "API Key (one per line for batch mode)": "API キー (バッチモード時は1行に1つ)", "API Key (Production)": "APIキー(本番)", "API Key (Sandbox)": "APIキー(サンドボックス)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "プロバイダー、グループ、タイプ、エンドポイント、タグでモデルを絞り込みます。", "Filter models by type, endpoint, vendor, group and tags": "タイプ、エンドポイント、ベンダー、グループ、タグでモデルをフィルタリング", "Filter models...": "モデルをフィルタリング...", - "Filter the model analytics view by time range and user.": "時間範囲とユーザーでモデル分析ビューを絞り込みます。", + "Filter the model analytics view by time range, user and API key.": "時間範囲、ユーザー、API キーでモデル分析ビューを絞り込みます。", "Filter the traffic flow view by time range and user.": "時間範囲とユーザーでトラフィックフロー表示を絞り込みます。", "Filter...": "フィルター…", "Filters": "フィルター", @@ -2841,6 +2843,7 @@ "No API key yet": "API キーはまだありません", "No API keys available. Create your first API key to get started.": "利用可能なAPIキーがありません。最初のAPIキーを作成して開始してください。", "No API Keys Found": "APIキーが見つかりません", + "No API key found.": "API キーが見つかりません。", "No API routes configured": "APIルートが設定されていません", "No API tokens": "API キーなし", "No app usage data available for this model.": "このモデルのアプリ利用データはまだありません。", @@ -3984,6 +3987,7 @@ "Search": "検索", "Search by name or URL...": "名前またはURLで検索...", "Search by order number...": "注文番号で検索...", + "Search API keys...": "API キーを検索...", "Search channel type...": "チャネルタイプを検索...", "Search chat presets...": "チャットプリセットを検索...", "Search colors...": "色を検索...", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index a1c18a35657e..18b7d267659d 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -279,6 +279,7 @@ "Ali": "Alibaba Байлянь", "Alipay": "Alipay", "All": "Все", + "All API keys": "Все API-ключи", "All API tokens": "Все API-ключи", "All categories": "Все категории", "All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.", @@ -381,6 +382,7 @@ "API info updated. Click \"Save Settings\" to apply.": "Информация API обновлена. Нажмите «Сохранить настройки», чтобы применить.", "API key": "Ключ API", "API Key": "Ключ API", + "API Key Filter": "Фильтр API-ключа", "API Key (one per line for batch mode)": "Ключ API (по одному на строку для пакетного режима)", "API Key (Production)": "API-ключ (Продакшн)", "API Key (Sandbox)": "API-ключ (Песочница)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "Фильтруйте модели по поставщику, группе, типу, endpoint и тегам.", "Filter models by type, endpoint, vendor, group and tags": "Фильтровать модели по типу, точке доступа, поставщику, группе и тегам", "Filter models...": "Фильтровать модели...", - "Filter the model analytics view by time range and user.": "Фильтруйте представление аналитики моделей по периоду и пользователю.", + "Filter the model analytics view by time range, user and API key.": "Фильтруйте представление аналитики моделей по периоду, пользователю и API-ключу.", "Filter the traffic flow view by time range and user.": "Фильтруйте представление потока трафика по диапазону времени и пользователю.", "Filter...": "Фильтр...", "Filters": "Фильтры", @@ -2842,6 +2844,7 @@ "No API keys available. Create your first API key to get started.": "Нет доступных ключей API. Создайте свой первый ключ API, чтобы начать.", "No API Keys Found": "Ключи API не найдены", "No API routes configured": "Нет настроенных маршрутов API", + "No API key found.": "API-ключ не найден.", "No API tokens": "Нет API-ключей", "No app usage data available for this model.": "Данные об использовании приложений для этой модели пока недоступны.", "No apps match the selected filters": "Нет приложений, соответствующих фильтрам", @@ -3984,6 +3987,7 @@ "Search": "Поиск", "Search by name or URL...": "Поиск по имени или URL...", "Search by order number...": "Поиск по номеру заказа...", + "Search API keys...": "Поиск API-ключей...", "Search channel type...": "Поиск типа канала...", "Search chat presets...": "Поиск предустановок чата...", "Search colors...": "Поиск цветов...", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 19cdddb1621d..09ffb0dad559 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -279,6 +279,7 @@ "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "All", + "All API keys": "Tất cả Khóa API", "All API tokens": "Tất cả khóa API", "All categories": "Tất cả danh mục", "All conditions must match before this tier is used.": "Tất cả điều kiện phải khớp trước khi tầng này được sử dụng.", @@ -380,6 +381,7 @@ "API info saved successfully": "Đã lưu thông tin API thành công", "API info updated. Click \"Save Settings\" to apply.": "Thông tin API đã được cập nhật. Nhấp vào \"Lưu Cài đặt\" để áp dụng.", "API key": "Khóa API", + "API Key Filter": "Bộ lọc Khóa API", "API Key": "Khóa API", "API Key (one per line for batch mode)": "Khóa API (mỗi khóa một dòng cho chế độ hàng loạt)", "API Key (Production)": "API Key (Sản xuất)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "Lọc mô hình theo nhà cung cấp, nhóm, loại, endpoint và thẻ.", "Filter models by type, endpoint, vendor, group and tags": "Lọc mô hình theo loại, endpoint, nhà cung cấp, nhóm và thẻ", "Filter models...": "Lọc mô hình...", - "Filter the model analytics view by time range and user.": "Lọc chế độ xem phân tích mô hình theo khoảng thời gian và người dùng.", + "Filter the model analytics view by time range, user and API key.": "Lọc chế độ xem phân tích mô hình theo khoảng thời gian, người dùng và Khóa API.", "Filter the traffic flow view by time range and user.": "Lọc chế độ xem luồng lưu lượng theo khoảng thời gian và người dùng.", "Filter...": "Lọc...", "Filters": "Bộ lọc", @@ -2844,6 +2846,7 @@ "No API routes configured": "Chưa có tuyến API nào được cấu hình", "No API tokens": "Không có khóa API", "No app usage data available for this model.": "Chưa có dữ liệu sử dụng ứng dụng cho mô hình này.", + "No API key found.": "Không tìm thấy Khóa API.", "No apps match the selected filters": "Không có ứng dụng phù hợp bộ lọc", "No Auth": "Không xác thực", "No available models": "Không có mô hình khả dụng", @@ -3984,6 +3987,7 @@ "Search": "Tìm kiếm", "Search by name or URL...": "Tìm kiếm theo tên hoặc URL...", "Search by order number...": "Tìm kiếm theo số đơn hàng...", + "Search API keys...": "Tìm kiếm Khóa API...", "Search channel type...": "Tìm loại kênh...", "Search chat presets...": "Tìm kiếm cài đặt sẵn trò chuyện...", "Search colors...": "Tìm kiếm màu sắc...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ee80b960bd2c..2471ea5a7acc 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -279,6 +279,7 @@ "Ali": "阿里百炼", "Alipay": "支付宝", "All": "全部", + "All API keys": "全部 API 密钥", "All API tokens": "全部 API 密钥", "All categories": "全部分类", "All conditions must match before this tier is used.": "所有条件都匹配后才会使用此阶梯。", @@ -381,6 +382,7 @@ "API info updated. Click \"Save Settings\" to apply.": "API 信息已更新。点击 \"保存设置\" 以应用。", "API key": "API 密钥", "API Key": "API 密钥", + "API Key Filter": "API 密钥筛选", "API Key (one per line for batch mode)": "API 密钥(批量模式下每行一个)", "API Key (Production)": "API 密钥(生产)", "API Key (Sandbox)": "API 密钥(沙盒)", @@ -1985,7 +1987,7 @@ "Filter models by provider, group, type, endpoint, and tags.": "按供应商、分组、类型、端点和标签筛选模型。", "Filter models by type, endpoint, vendor, group and tags": "按类型、端点、供应商、分组和标签筛选模型", "Filter models...": "筛选模型...", - "Filter the model analytics view by time range and user.": "按时间范围和用户筛选模型分析视图。", + "Filter the model analytics view by time range, user and API key.": "按时间范围、用户和 API 密钥筛选模型分析视图。", "Filter the traffic flow view by time range and user.": "按时间范围和用户筛选分流图视图。", "Filter...": "筛选...", "Filters": "筛选器", @@ -2846,6 +2848,7 @@ "No app usage data available for this model.": "该模型暂无应用使用数据。", "No apps match the selected filters": "没有匹配筛选条件的应用", "No Auth": "无认证", + "No API key found.": "未找到 API 密钥。", "No available models": "没有可用模型", "No available Web chat links": "没有可用的 Web 聊天链接", "No backup": "无备份", @@ -3984,6 +3987,7 @@ "Search": "搜索", "Search by name or URL...": "按名称或 URL 搜索...", "Search by order number...": "按订单号搜索...", + "Search API keys...": "搜索 API 密钥...", "Search channel type...": "搜索渠道类型...", "Search chat presets...": "搜索聊天预设...", "Search colors...": "搜索颜色...",