Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
Trophy,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { Fragment, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Expand All @@ -28,30 +28,45 @@ import type { LeaderboardPeriod } from "@/repository/leaderboard";
export interface ColumnDef<T> {
header: string;
className?: string;
cell: (row: T, index: number) => React.ReactNode;
/**
* index 语义:
* - 父行:按当前排序后的全局行序(从 0 开始)
* - 子行:父行内的子行序(从 0 开始)
*/
cell: (row: T, index: number, isSubRow?: boolean) => React.ReactNode;
sortKey?: string; // 用于排序的字段名
getValue?: (row: T) => number | string; // 获取排序值的函数
defaultBold?: boolean; // 默认加粗(无排序时显示加粗)
}

type SortDirection = "asc" | "desc" | null;

interface LeaderboardTableProps<T> {
data: T[];
interface LeaderboardTableProps<TParent, TSub = TParent> {
data: TParent[];
period: LeaderboardPeriod;
columns: ColumnDef<T>[]; // 不包含"排名"列,组件会自动添加
getRowKey?: (row: T, index: number) => string | number;
renderExpandedContent?: (row: T, index: number) => React.ReactNode | null;
columns: ColumnDef<TParent | TSub>[]; // 不包含"排名"列,组件会自动添加
getRowKey?: (row: TParent, index: number) => string | number;
/** 返回子行数据(非空且长度 > 0 时,父行展示可展开图标) */
getSubRows?: (row: TParent, index: number) => TSub[] | null | undefined;
/** 子行的 React key(默认使用 `${parentKey}-${subIndex}` 组合) */
getSubRowKey?: (
subRow: TSub,
parentRow: TParent,
parentIndex: number,
subIndex: number
) => string | number;
}

export function LeaderboardTable<T>({
export function LeaderboardTable<TParent, TSub = TParent>({
data,
period,
columns,
getRowKey,
renderExpandedContent,
}: LeaderboardTableProps<T>) {
getSubRows,
getSubRowKey,
}: LeaderboardTableProps<TParent, TSub>) {
const t = useTranslations("dashboard.leaderboard");
type TableRow = TParent | TSub;

// 排序状态
const [sortKey, setSortKey] = useState<string | null>(null);
Expand All @@ -71,8 +86,16 @@ export function LeaderboardTable<T>({
});
};

// 当调用方未提供稳定 rowKey 时(回退到 index),排序会导致展开态错位;此时在排序/数据变化时清空展开态,至少避免错位展开。
// biome-ignore lint/correctness/useExhaustiveDependencies: 依赖用于在排序/数据变化时触发清空,避免 index key 造成错位展开
useEffect(() => {
if (!getRowKey) {
setExpandedRows(new Set());
}
}, [data, sortKey, sortDirection, getRowKey]);
Comment on lines +89 to +95
Copy link

Choose a reason for hiding this comment

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

clearing expanded rows on data/sort changes prevents users from keeping rows expanded when changing sort order

consider preserving expand state across sorts by always requiring stable getRowKey instead of clearing on change

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx
Line: 84-90

Comment:
clearing expanded rows on data/sort changes prevents users from keeping rows expanded when changing sort order

consider preserving expand state across sorts by always requiring stable `getRowKey` instead of clearing on change

How can I resolve this? If you propose a fix, please make it concise.


// 判断列是否需要加粗
const getShouldBold = (col: ColumnDef<T>) => {
const getShouldBold = (col: ColumnDef<TableRow>) => {
const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null;
const noSorting = sortKey === null;
return isActiveSortColumn || (col.defaultBold && noSorting);
Expand Down Expand Up @@ -229,27 +252,34 @@ export function LeaderboardTable<T>({
const rank = index + 1;
const isTopThree = rank <= 3;
const rowKey = getRowKey ? (getRowKey(row, index) ?? index) : index;
const hasExpandable = renderExpandedContent != null;
const expandedContent = hasExpandable ? renderExpandedContent(row, index) : null;
const isExpanded = expandedRows.has(rowKey);
const subRows = getSubRows ? getSubRows(row, index) : null;
const hasExpandable = (subRows?.length ?? 0) > 0;
const isExpanded = hasExpandable && expandedRows.has(rowKey);

return (
<Fragment key={rowKey}>
<TableRow
className={`${isTopThree ? "bg-muted/50" : ""} ${hasExpandable && expandedContent ? "cursor-pointer" : ""}`}
onClick={
hasExpandable && expandedContent ? () => toggleRow(rowKey) : undefined
}
>
<TableRow className={`${isTopThree ? "bg-muted/50" : ""}`}>
<TableCell>
<div className="flex items-center gap-1">
{hasExpandable && expandedContent ? (
isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)
) : null}
{hasExpandable ? (
<button
type="button"
className="inline-flex items-center cursor-pointer rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => toggleRow(rowKey)}
aria-expanded={isExpanded}
aria-label={
isExpanded ? t("collapseModelStats") : t("expandModelStats")
}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</button>
) : (
<div className="h-4 w-4" aria-hidden="true" />
)}
{getRankBadge(rank)}
</div>
</TableCell>
Expand All @@ -260,21 +290,38 @@ export function LeaderboardTable<T>({
key={idx}
className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
>
{col.cell(row, index)}
{col.cell(row, index, false)}
</TableCell>
);
})}
</TableRow>
{isExpanded && expandedContent && (
<TableRow
key={`${rowKey}-expanded`}
className="bg-muted/30 hover:bg-muted/30"
>
<TableCell colSpan={columns.length + 1} className="p-0">
{expandedContent}
</TableCell>
</TableRow>
)}
{isExpanded &&
(subRows ?? []).map((subRow, subIndex) => {
const rawSubKey = getSubRowKey
? getSubRowKey(subRow, row, index, subIndex)
: subIndex;
const subKey = `${rowKey}-${String(rawSubKey)}`;
return (
<TableRow key={subKey} className="bg-muted/30 hover:bg-muted/30">
<TableCell>
<div className="flex items-center gap-1">
<div className="h-4 w-4" />
</div>
</TableCell>
{columns.map((col, idx) => {
const shouldBold = getShouldBold(col);
return (
<TableCell
key={idx}
className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
>
{col.cell(subRow, subIndex, true)}
</TableCell>
);
})}
</TableRow>
);
})}
</Fragment>
);
})}
Expand Down
Loading
Loading