diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 91ef7163a..e752d461b 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -322,6 +322,10 @@ "adminAction": "Enable this permission.", "userAction": "Please contact an administrator to enable this permission.", "systemSettings": "System Settings" + }, + "filters": { + "userTagsPlaceholder": "Filter by user tags...", + "userGroupsPlaceholder": "Filter by user groups..." } }, "sessions": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index bc0467dc5..030f0868c 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -321,6 +321,10 @@ "adminAction": "この権限を有効にします。", "userAction": "この権限を有効にするには、管理者に連絡してください。", "systemSettings": "システム設定" + }, + "filters": { + "userTagsPlaceholder": "ユーザータグでフィルタ...", + "userGroupsPlaceholder": "ユーザーグループでフィルタ..." } }, "sessions": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1b332bc7e..e3d6e9f5b 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -321,6 +321,10 @@ "adminAction": "Включить это разрешение.", "userAction": "Пожалуйста, свяжитесь с администратором, чтобы включить это разрешение.", "systemSettings": "Настройки системы" + }, + "filters": { + "userTagsPlaceholder": "Фильтр по тегам пользователей...", + "userGroupsPlaceholder": "Фильтр по группам пользователей..." } }, "sessions": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 40b1be77a..0df593e25 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -322,6 +322,10 @@ "adminAction": "开启此权限。", "userAction": "请联系管理员开启此权限。", "systemSettings": "系统设置" + }, + "filters": { + "userTagsPlaceholder": "按用户标签筛选...", + "userGroupsPlaceholder": "按用户分组筛选..." } }, "sessions": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7fb6ace8b..cff75d534 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -322,6 +322,10 @@ "adminAction": "開啟此權限。", "userAction": "請聯繫管理員開啟此權限。", "systemSettings": "系統設定" + }, + "filters": { + "userTagsPlaceholder": "按使用者標籤篩選...", + "userGroupsPlaceholder": "按使用者群組篩選..." } }, "sessions": { diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 89314fd40..4cd1f6ef6 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -7,6 +7,7 @@ import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_component import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TagInput } from "@/components/ui/tag-input"; import { formatTokenAmount } from "@/lib/utils"; import type { DateRangeParams, @@ -51,6 +52,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const [period, setPeriod] = useState(initialPeriod); const [dateRange, setDateRange] = useState(undefined); const [providerTypeFilter, setProviderTypeFilter] = useState("all"); + const [userTagFilters, setUserTagFilters] = useState([]); + const [userGroupFilters, setUserGroupFilters] = useState([]); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -96,6 +99,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) { url += `&providerType=${encodeURIComponent(providerTypeFilter)}`; } + if (scope === "user") { + if (userTagFilters.length > 0) { + url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; + } + if (userGroupFilters.length > 0) { + url += `&userGroups=${encodeURIComponent(userGroupFilters.join(","))}`; + } + } const res = await fetch(url); if (!res.ok) { @@ -120,7 +131,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return () => { cancelled = true; }; - }, [scope, period, dateRange, providerTypeFilter, t]); + }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]); const handlePeriodChange = useCallback( (newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => { @@ -369,6 +380,31 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) : null} + {scope === "user" && isAdmin && ( +
+
+ +
+
+ +
+
+ )} + {/* Date range picker with quick period buttons */}
{ + if (!param) return undefined; + const items = param + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .slice(0, 20); + return items.length > 0 ? items : undefined; + }; + + let userTags: string[] | undefined; + let userGroups: string[] | undefined; + if (scope === "user") { + userTags = parseListParam(userTagsParam); + userGroups = parseListParam(userGroupsParam); + } + // 使用 Redis 乐观缓存获取数据 const rawData = await getLeaderboardWithCache( period, systemSettings.currencyDisplay, scope, dateRange, - providerType ? { providerType } : undefined + { providerType, userTags, userGroups } ); // 格式化金额字段 @@ -162,6 +181,8 @@ export async function GET(request: NextRequest) { scope, dateRange, providerType, + userTags, + userGroups, entriesCount: data.length, }); diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index fb6e55428..d114d0206 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -28,6 +28,7 @@ import { type ModelLeaderboardEntry, type ProviderCacheHitRateLeaderboardEntry, type ProviderLeaderboardEntry, + type UserLeaderboardFilters, } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; import { getRedisClient } from "./client"; @@ -43,6 +44,8 @@ type LeaderboardData = export interface LeaderboardFilters { providerType?: ProviderType; + userTags?: string[]; + userGroups?: string[]; } /** @@ -59,24 +62,35 @@ function buildCacheKey( const tz = getEnvConfig().TZ; // ensure date formatting aligns with configured timezone const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; + let userFilterSuffix = ""; + if (scope === "user") { + const tagsPart = filters?.userTags?.length + ? `:tags:${[...filters.userTags].sort().join(",")}` + : ""; + const groupsPart = filters?.userGroups?.length + ? `:groups:${[...filters.userGroups].sort().join(",")}` + : ""; + userFilterSuffix = tagsPart + groupsPart; + } + if (period === "custom" && dateRange) { // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD - return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}`; + return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "daily") { // leaderboard:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, tz, "yyyy-MM-dd"); - return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}`; + return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "weekly") { // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, tz, "yyyy-'W'ww"); - return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}`; + return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "monthly") { // leaderboard:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, tz, "yyyy-MM"); - return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}`; + return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else { // allTime: leaderboard:{scope}:allTime:USD (no date component) - return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}`; + return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } } @@ -89,10 +103,15 @@ async function queryDatabase( dateRange?: DateRangeParams, filters?: LeaderboardFilters ): Promise { + const userFilters: UserLeaderboardFilters | undefined = + scope === "user" && (filters?.userTags?.length || filters?.userGroups?.length) + ? { userTags: filters.userTags, userGroups: filters.userGroups } + : undefined; + // 处理自定义日期范围 if (period === "custom" && dateRange) { if (scope === "user") { - return await findCustomRangeLeaderboard(dateRange); + return await findCustomRangeLeaderboard(dateRange, userFilters); } if (scope === "provider") { return await findCustomRangeProviderLeaderboard(dateRange, filters?.providerType); @@ -106,15 +125,15 @@ async function queryDatabase( if (scope === "user") { switch (period) { case "daily": - return await findDailyLeaderboard(); + return await findDailyLeaderboard(userFilters); case "weekly": - return await findWeeklyLeaderboard(); + return await findWeeklyLeaderboard(userFilters); case "monthly": - return await findMonthlyLeaderboard(); + return await findMonthlyLeaderboard(userFilters); case "allTime": - return await findAllTimeLeaderboard(); + return await findAllTimeLeaderboard(userFilters); default: - return await findDailyLeaderboard(); + return await findDailyLeaderboard(userFilters); } } if (scope === "provider") { diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 21aee7162..395d1a7ef 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -19,6 +19,16 @@ export interface LeaderboardEntry { totalTokens: number; } +/** + * 用户排行榜筛选参数 + */ +export interface UserLeaderboardFilters { + /** 按用户标签筛选(OR 逻辑:匹配任一标签) */ + userTags?: string[]; + /** 按用户分组筛选(OR 逻辑:匹配任一分组) */ + userGroups?: string[]; +} + /** * 供应商排行榜条目类型 */ @@ -62,35 +72,43 @@ export interface ModelLeaderboardEntry { * 查询今日消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai) */ -export async function findDailyLeaderboard(): Promise { +export async function findDailyLeaderboard( + userFilters?: UserLeaderboardFilters +): Promise { const timezone = getEnvConfig().TZ; - return findLeaderboardWithTimezone("daily", timezone); + return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters); } /** * 查询本月消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) */ -export async function findMonthlyLeaderboard(): Promise { +export async function findMonthlyLeaderboard( + userFilters?: UserLeaderboardFilters +): Promise { const timezone = getEnvConfig().TZ; - return findLeaderboardWithTimezone("monthly", timezone); + return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters); } /** * 查询本周消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于配置时区 */ -export async function findWeeklyLeaderboard(): Promise { +export async function findWeeklyLeaderboard( + userFilters?: UserLeaderboardFilters +): Promise { const timezone = getEnvConfig().TZ; - return findLeaderboardWithTimezone("weekly", timezone); + return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters); } /** * 查询全部时间消耗排行榜(不限制数量) */ -export async function findAllTimeLeaderboard(): Promise { +export async function findAllTimeLeaderboard( + userFilters?: UserLeaderboardFilters +): Promise { const timezone = getEnvConfig().TZ; - return findLeaderboardWithTimezone("allTime", timezone); + return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters); } /** @@ -151,8 +169,40 @@ function buildDateCondition( async function findLeaderboardWithTimezone( period: LeaderboardPeriod, timezone: string, - dateRange?: DateRangeParams + dateRange?: DateRangeParams, + userFilters?: UserLeaderboardFilters ): Promise { + const whereConditions = [ + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + buildDateCondition(period, timezone, dateRange), + ]; + + const normalizedTags = (userFilters?.userTags ?? []).map((t) => t.trim()).filter(Boolean); + let tagFilterCondition: ReturnType | undefined; + if (normalizedTags.length > 0) { + const tagConditions = normalizedTags.map((tag) => sql`${users.tags} ? ${tag}`); + tagFilterCondition = sql`(${sql.join(tagConditions, sql` OR `)})`; + } + + const normalizedGroups = (userFilters?.userGroups ?? []).map((g) => g.trim()).filter(Boolean); + let groupFilterCondition: ReturnType | undefined; + if (normalizedGroups.length > 0) { + const groupConditions = normalizedGroups.map( + (group) => + sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))` + ); + groupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`; + } + + if (tagFilterCondition && groupFilterCondition) { + whereConditions.push(sql`(${tagFilterCondition} OR ${groupFilterCondition})`); + } else if (tagFilterCondition) { + whereConditions.push(tagFilterCondition); + } else if (groupFilterCondition) { + whereConditions.push(groupFilterCondition); + } + const rankings = await db .select({ userId: messageRequest.userId, @@ -171,13 +221,7 @@ async function findLeaderboardWithTimezone( }) .from(messageRequest) .innerJoin(users, and(sql`${messageRequest.userId} = ${users.id}`, isNull(users.deletedAt))) - .where( - and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - buildDateCondition(period, timezone, dateRange) - ) - ) + .where(and(...whereConditions)) .groupBy(messageRequest.userId, users.name) .orderBy(desc(sql`sum(${messageRequest.costUsd})`)); @@ -194,10 +238,11 @@ async function findLeaderboardWithTimezone( * 查询自定义日期范围消耗排行榜 */ export async function findCustomRangeLeaderboard( - dateRange: DateRangeParams + dateRange: DateRangeParams, + userFilters?: UserLeaderboardFilters ): Promise { const timezone = getEnvConfig().TZ; - return findLeaderboardWithTimezone("custom", timezone, dateRange); + return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters); } /**