Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@
"adminAction": "この権限を有効にします。",
"userAction": "この権限を有効にするには、管理者に連絡してください。",
"systemSettings": "システム設定"
},
"filters": {
"userTagsPlaceholder": "ユーザータグでフィルタ...",
"userGroupsPlaceholder": "ユーザーグループでフィルタ..."
}
},
"sessions": {
Expand Down
4 changes: 4 additions & 0 deletions messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@
"adminAction": "Включить это разрешение.",
"userAction": "Пожалуйста, свяжитесь с администратором, чтобы включить это разрешение.",
"systemSettings": "Настройки системы"
},
"filters": {
"userTagsPlaceholder": "Фильтр по тегам пользователей...",
"userGroupsPlaceholder": "Фильтр по группам пользователей..."
}
},
"sessions": {
Expand Down
4 changes: 4 additions & 0 deletions messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@
"adminAction": "开启此权限。",
"userAction": "请联系管理员开启此权限。",
"systemSettings": "系统设置"
},
"filters": {
"userTagsPlaceholder": "按用户标签筛选...",
"userGroupsPlaceholder": "按用户分组筛选..."
}
Comment on lines +325 to 329
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

建议确认「userGroups」中文术语是否应为「用户组/用户群组」而非「用户分组」。
如果系统内其它地方把 group 统一翻译成「分组」,那现在的文案也没问题;主要是避免与「供应商分组」等概念混淆。

🤖 Prompt for AI Agents
In @messages/zh-CN/dashboard.json around lines 325 - 329, The translation for
the key "userGroupsPlaceholder" may be inconsistent—confirm whether the standard
term for "group" in the project is "分组" or "组/用户群组" and update the value of
"userGroupsPlaceholder" in messages/zh-CN/dashboard.json accordingly; locate the
object "filters" and change "userGroupsPlaceholder": "按用户分组筛选..." to the agreed
canonical translation (e.g., "按用户组筛选..." or "按用户群组筛选...") so it matches other
occurrences of group-related keys across the codebase and avoids confusion with
other domain-specific "分组" usages like supplier grouping.

},
"sessions": {
Expand Down
4 changes: 4 additions & 0 deletions messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@
"adminAction": "開啟此權限。",
"userAction": "請聯繫管理員開啟此權限。",
"systemSettings": "系統設定"
},
"filters": {
"userTagsPlaceholder": "按使用者標籤篩選...",
"userGroupsPlaceholder": "按使用者群組篩選..."
}
},
"sessions": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,6 +52,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
const [period, setPeriod] = useState<LeaderboardPeriod>(initialPeriod);
const [dateRange, setDateRange] = useState<DateRangeParams | undefined>(undefined);
const [providerTypeFilter, setProviderTypeFilter] = useState<ProviderType | "all">("all");
const [userTagFilters, setUserTagFilters] = useState<string[]>([]);
const [userGroupFilters, setUserGroupFilters] = useState<string[]>([]);
const [data, setData] = useState<AnyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -369,6 +380,31 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
) : null}
</div>

{scope === "user" && isAdmin && (
<div className="flex flex-wrap gap-4 mb-4">
<div className="flex-1 min-w-[200px] max-w-[300px]">
<TagInput
value={userTagFilters}
onChange={setUserTagFilters}
placeholder={t("filters.userTagsPlaceholder")}
disabled={loading}
maxTags={20}
clearable
/>
</div>
<div className="flex-1 min-w-[200px] max-w-[300px]">
<TagInput
value={userGroupFilters}
onChange={setUserGroupFilters}
placeholder={t("filters.userGroupsPlaceholder")}
disabled={loading}
maxTags={20}
clearable
/>
</div>
</div>
Comment on lines +384 to +405
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The two TagInput components for user tags and user groups are nearly identical. To reduce duplication and improve maintainability, you can define the filter configurations in an array and render the components in a loop. This makes the code cleaner and easier to extend with more filters in the future.

        <div className="flex flex-wrap gap-4 mb-4">
          {[
            {
              value: userTagFilters,
              onChange: setUserTagFilters,
              placeholder: t("filters.userTagsPlaceholder"),
            },
            {
              value: userGroupFilters,
              onChange: setUserGroupFilters,
              placeholder: t("filters.userGroupsPlaceholder"),
            },
          ].map((config, index) => (
            <div key={index} className="flex-1 min-w-[200px] max-w-[300px]">
              <TagInput
                value={config.value}
                onChange={config.onChange}
                placeholder={config.placeholder}
                disabled={loading}
                maxTags={20}
                clearable
              />
            </div>
          ))}
        </div>

)}

{/* Date range picker with quick period buttons */}
<div className="mb-6">
<DateRangePicker
Expand Down
23 changes: 22 additions & 1 deletion src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export async function GET(request: NextRequest) {
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
const providerTypeParam = searchParams.get("providerType");
const userTagsParam = searchParams.get("userTags");
const userGroupsParam = searchParams.get("userGroups");

if (!VALID_PERIODS.includes(period)) {
return NextResponse.json(
Expand Down Expand Up @@ -125,13 +127,30 @@ export async function GET(request: NextRequest) {
providerType = providerTypeParam;
}

const parseListParam = (param: string | null): string[] | undefined => {
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 }
);

// 格式化金额字段
Expand Down Expand Up @@ -162,6 +181,8 @@ export async function GET(request: NextRequest) {
scope,
dateRange,
providerType,
userTags,
userGroups,
entriesCount: data.length,
});

Expand Down
41 changes: 30 additions & 11 deletions src/lib/redis/leaderboard-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -43,6 +44,8 @@ type LeaderboardData =

export interface LeaderboardFilters {
providerType?: ProviderType;
userTags?: string[];
userGroups?: string[];
}

/**
Expand All @@ -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}`;
}
}

Expand All @@ -89,10 +103,15 @@ async function queryDatabase(
dateRange?: DateRangeParams,
filters?: LeaderboardFilters
): Promise<LeaderboardData> {
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);
Expand All @@ -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") {
Expand Down
Loading
Loading