누적 랭킹 보기 탭 추가
parent
39c2003080
commit
a8e20d5ac8
|
|
@ -9,11 +9,17 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Match, UserPoints
|
||||
from ..scoring import is_excluded
|
||||
from ..schemas import LeaderboardOut, StandingOut
|
||||
from ..scoring import is_excluded, score_prediction
|
||||
from ..schemas import (
|
||||
AILeaderboardOut,
|
||||
AIStandingOut,
|
||||
LeaderboardOut,
|
||||
StandingOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
|
||||
|
||||
|
|
@ -62,3 +68,47 @@ async def leaderboard(
|
|||
for i, r in enumerate(ranked[:limit])
|
||||
]
|
||||
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
|
||||
|
||||
|
||||
@router.get("/ai", response_model=AILeaderboardOut)
|
||||
async def ai_leaderboard(db: AsyncSession = Depends(get_db)) -> AILeaderboardOut:
|
||||
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
|
||||
|
||||
별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에
|
||||
항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.)
|
||||
"""
|
||||
matches = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.where(Match.result_outcome.is_not(None))
|
||||
.options(selectinload(Match.predictions))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# model → [points, exact_count, matches_played]
|
||||
agg: dict[str, list[int]] = {}
|
||||
for m in matches:
|
||||
for p in m.predictions:
|
||||
pts = score_prediction(p.score_a, p.score_b, m.result_score_a, m.result_score_b)
|
||||
row = agg.setdefault(p.model, [0, 0, 0])
|
||||
row[0] += pts
|
||||
row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0
|
||||
row[2] += 1
|
||||
|
||||
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
|
||||
ranked = sorted(
|
||||
agg.items(),
|
||||
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)),
|
||||
)
|
||||
|
||||
standings = [
|
||||
AIStandingOut(
|
||||
rank=i + 1,
|
||||
model=model, # type: ignore[arg-type]
|
||||
totalPoints=pts,
|
||||
exactCount=exact,
|
||||
matchesPlayed=played,
|
||||
)
|
||||
for i, (model, (pts, exact, played)) in enumerate(ranked)
|
||||
]
|
||||
return AILeaderboardOut(standings=standings, scoredMatches=len(matches))
|
||||
|
|
|
|||
|
|
@ -124,6 +124,20 @@ class LeaderboardOut(BaseModel):
|
|||
scoredMatches: int
|
||||
|
||||
|
||||
# ── AI 모델 랭킹 (종료 경기 누적, 매 조회 시 재계산) ──────────
|
||||
class AIStandingOut(BaseModel):
|
||||
rank: int
|
||||
model: ModelName
|
||||
totalPoints: int
|
||||
exactCount: int
|
||||
matchesPlayed: int
|
||||
|
||||
|
||||
class AILeaderboardOut(BaseModel):
|
||||
standings: list[AIStandingOut]
|
||||
scoredMatches: int
|
||||
|
||||
|
||||
# ── 관리자 ──────────────────────────────────────────────────
|
||||
class AdminAIPredictionIn(BaseModel):
|
||||
matchId: str
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
submitPrediction,
|
||||
} from "@/lib/api";
|
||||
import Countdown from "./Countdown";
|
||||
import RankingBoard from "./RankingBoard";
|
||||
import TeamFlag from "./TeamFlag";
|
||||
|
||||
type Step = "form" | "done";
|
||||
|
|
@ -457,6 +458,9 @@ export default function Arena({
|
|||
</section>
|
||||
)}
|
||||
|
||||
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
|
||||
<RankingBoard lang={lang} />
|
||||
|
||||
{/* ===== 골드 상금 (003) ===== */}
|
||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { type Lang, dict } from "@/lib/i18n";
|
||||
import { fetchRanking } from "@/lib/api";
|
||||
import type { RankingEntry, RankingTab } from "@/lib/types";
|
||||
|
||||
// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘).
|
||||
// 경기 페이지(골드 카드 위)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
|
||||
// 데이터는 서버 누적(경기 종료·채점마다 갱신) — 펼치거나 탭 바꿀 때마다 최신 조회.
|
||||
export default function RankingBoard({
|
||||
lang = "ko",
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
lang?: Lang;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const t = dict(lang);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [tab, setTab] = useState<RankingTab>("voters");
|
||||
const [rows, setRows] = useState<RankingEntry[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
setLoaded(false);
|
||||
fetchRanking(tab)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setRows(r);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setRows([]);
|
||||
setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [open, tab]);
|
||||
|
||||
return (
|
||||
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||||
<h3 className="text-[19px] font-extrabold">{t.lbTitle}</h3>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* 탭 */}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
["voters", t.lbTabVoters],
|
||||
["ai", t.lbTabAI],
|
||||
] as [RankingTab, string][]
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setTab(val)}
|
||||
className={`rounded-xl py-2.5 text-[15px] font-extrabold transition ${
|
||||
tab === val
|
||||
? "bg-[var(--share)] text-white"
|
||||
: "border border-[var(--line-d)] text-[var(--ink-muted)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loaded && rows.length === 0 ? (
|
||||
<p className="mt-4 py-3 text-center text-[13px] text-[var(--ink-muted)]">
|
||||
{t.lbEmpty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* 헤더 라벨 */}
|
||||
<div className="mt-3 flex items-center justify-between px-3 text-[12px] font-bold text-[var(--ink-muted)]">
|
||||
<span>{tab === "ai" ? t.lbTabAI : t.lbTabVoters}</span>
|
||||
<span>{t.lbPoints}</span>
|
||||
</div>
|
||||
|
||||
{/* TOP 10 목록 */}
|
||||
<ol className="mt-1.5 flex flex-col gap-1.5">
|
||||
{rows.map((r, i) => {
|
||||
const top3 = i < 3;
|
||||
return (
|
||||
<li
|
||||
key={`${r.label}-${i}`}
|
||||
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 ${
|
||||
top3
|
||||
? "border-[var(--share)]/50 bg-[rgba(166,94,255,0.08)]"
|
||||
: "border-[var(--line-d)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-6 shrink-0 text-center font-mono text-[16px] font-extrabold tabular-nums ${
|
||||
top3 ? "text-[var(--share)]" : "text-[var(--ink-muted)]"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[16px] font-bold text-white">
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[16px] font-extrabold tabular-nums text-white">
|
||||
{r.points.toLocaleString()}
|
||||
<span className="ml-0.5 text-[12px] text-[var(--ink-muted)]">
|
||||
{t.lbUnit}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
|
||||
{!defaultOpen && (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--share)] bg-[rgba(166,94,255,0.12)] py-2.5 text-[15px] font-extrabold text-[var(--share)] transition active:scale-[0.99]"
|
||||
>
|
||||
{open ? t.lbClose : t.lbOpen}
|
||||
<span aria-hidden>{open ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
|
||||
import type {
|
||||
AILeaderboard,
|
||||
Comment,
|
||||
CommentList,
|
||||
CrowdStats,
|
||||
|
|
@ -7,6 +8,8 @@ import type {
|
|||
Match,
|
||||
ModelName,
|
||||
MyPrediction,
|
||||
RankingEntry,
|
||||
RankingTab,
|
||||
} from "./types";
|
||||
import type { Lang } from "./i18n";
|
||||
|
||||
|
|
@ -74,6 +77,24 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
|||
return http<Leaderboard>(`/leaderboard`);
|
||||
}
|
||||
|
||||
// AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산)
|
||||
export function getAILeaderboard(): Promise<AILeaderboard> {
|
||||
return http<AILeaderboard>(`/leaderboard/ai`);
|
||||
}
|
||||
|
||||
// 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환.
|
||||
// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴.
|
||||
export async function fetchRanking(tab: RankingTab): Promise<RankingEntry[]> {
|
||||
if (tab === "ai") {
|
||||
const { standings } = await getAILeaderboard();
|
||||
return standings.map((s) => ({ label: s.model, points: s.totalPoints }));
|
||||
}
|
||||
const { standings } = await getLeaderboard();
|
||||
return standings
|
||||
.slice(0, 10)
|
||||
.map((s) => ({ label: s.emailMasked, points: s.totalPoints }));
|
||||
}
|
||||
|
||||
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
|
||||
export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
|
||||
const e = email.trim();
|
||||
|
|
|
|||
|
|
@ -106,6 +106,15 @@ type Dict = {
|
|||
thisMatch: string;
|
||||
myVotesShowMore: (n: number) => string;
|
||||
myVotesShowLess: string;
|
||||
// 누적 랭킹 (참가자/AI 탭)
|
||||
lbTitle: string;
|
||||
lbTabVoters: string;
|
||||
lbTabAI: string;
|
||||
lbPoints: string;
|
||||
lbUnit: string;
|
||||
lbOpen: string;
|
||||
lbClose: string;
|
||||
lbEmpty: string;
|
||||
commentsTitle: string;
|
||||
commentsCount: (n: number) => string;
|
||||
commentPh: string;
|
||||
|
|
@ -204,6 +213,14 @@ export const DICT: Record<Lang, Dict> = {
|
|||
thisMatch: "이번 경기",
|
||||
myVotesShowMore: (n) => `더보기 +${n}`,
|
||||
myVotesShowLess: "접기",
|
||||
lbTitle: "누적 랭킹 TOP 10",
|
||||
lbTabVoters: "참가자",
|
||||
lbTabAI: "AI 모델",
|
||||
lbPoints: "누적 포인트",
|
||||
lbUnit: "P",
|
||||
lbOpen: "랭킹 보기",
|
||||
lbClose: "접기",
|
||||
lbEmpty: "아직 채점된 경기가 없습니다",
|
||||
commentsTitle: "한마디",
|
||||
commentsCount: (n) => `총 ${n}개`,
|
||||
commentPh: "응원 한마디 남기기...",
|
||||
|
|
@ -299,6 +316,14 @@ export const DICT: Record<Lang, Dict> = {
|
|||
thisMatch: "This match",
|
||||
myVotesShowMore: (n) => `Show ${n} more`,
|
||||
myVotesShowLess: "Show less",
|
||||
lbTitle: "Cumulative Ranking · TOP 10",
|
||||
lbTabVoters: "Players",
|
||||
lbTabAI: "AI models",
|
||||
lbPoints: "Points",
|
||||
lbUnit: "P",
|
||||
lbOpen: "View ranking",
|
||||
lbClose: "Collapse",
|
||||
lbEmpty: "No matches scored yet",
|
||||
commentsTitle: "Comments",
|
||||
commentsCount: (n) => `${n} total`,
|
||||
commentPh: "Leave a comment...",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,26 @@ export interface Leaderboard {
|
|||
scoredMatches: number;
|
||||
}
|
||||
|
||||
export interface AIStanding {
|
||||
rank: number;
|
||||
model: ModelName;
|
||||
totalPoints: number;
|
||||
exactCount: number;
|
||||
matchesPlayed: number;
|
||||
}
|
||||
|
||||
export interface AILeaderboard {
|
||||
standings: AIStanding[];
|
||||
scoredMatches: number;
|
||||
}
|
||||
|
||||
// 랭킹 위젯 공용 표시 항목 (참가자/AI 공통) — RankingBoard 가 사용
|
||||
export type RankingTab = "voters" | "ai";
|
||||
export interface RankingEntry {
|
||||
label: string; // 참가자: 마스킹 이메일 / AI: 모델명
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
|
||||
body: string;
|
||||
|
|
|
|||
|
|
@ -1,72 +1,25 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Hero from "@/components/Hero";
|
||||
import Footer from "@/components/Footer";
|
||||
import RankingBoard from "@/components/RankingBoard";
|
||||
import { useLang } from "@/lib/useLang";
|
||||
import { getLeaderboard } from "@/lib/api";
|
||||
import type { Leaderboard as LB } from "@/lib/types";
|
||||
|
||||
// L3. 리더보드 — 누적 포인트 1위. 데이터 없으면 안내 stub.
|
||||
// L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시.
|
||||
// 빈 상태·AI 탭·TOP10 렌더는 RankingBoard 위젯이 일괄 처리(매치 페이지와 동일 UI).
|
||||
export default function Leaderboard() {
|
||||
const lang = useLang();
|
||||
const [data, setData] = useState<LB | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getLeaderboard()
|
||||
.then((d) => alive && setData(d))
|
||||
.catch(() => alive && setData({ standings: [], scoredMatches: 0 }));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const empty = !data || data.standings.length === 0;
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<Hero back lang={lang} />
|
||||
<section className="mt-8">
|
||||
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
|
||||
|
||||
{empty ? (
|
||||
<div className="mt-4 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
|
||||
<p className="text-[15px] font-bold">
|
||||
{lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"}
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
|
||||
{lang === "en"
|
||||
? "Points accumulate each match — the overall #1 wins ₩1,000,000."
|
||||
: "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--line-d)]">
|
||||
{data!.standings.map((s) => (
|
||||
<div
|
||||
key={s.rank}
|
||||
className="flex items-center justify-between border-b border-[var(--line-d)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 text-center font-impact text-[18px] text-[var(--green)]">
|
||||
{s.rank}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold">{s.emailMasked}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[16px] font-extrabold">{s.totalPoints}</span>
|
||||
<span className="ml-1 text-[11px] text-[var(--ink-muted)]">
|
||||
{lang === "en" ? "pts" : "점"}
|
||||
</span>
|
||||
<span className="ml-2 text-[11px] text-[var(--ink-muted)]">
|
||||
{lang === "en"
|
||||
? `${s.exactCount} exact · ${s.matchesPlayed} played`
|
||||
: `정확 ${s.exactCount} · ${s.matchesPlayed}경기`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
|
||||
<RankingBoard lang={lang} defaultOpen />
|
||||
</section>
|
||||
<Footer lang={lang} />
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Reference in New Issue