275 行
12 KiB
TypeScript
275 行
12 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
|
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
|
|
import { toast } from "sonner";
|
|
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
|
|
import {
|
|
Area,
|
|
AreaChart,
|
|
CartesianGrid,
|
|
PolarAngleAxis,
|
|
PolarGrid,
|
|
PolarRadiusAxis,
|
|
Radar as RadarChartShape,
|
|
RadarChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
|
|
const NTRP_LEVELS = [
|
|
{ min: 1.0, max: 1.5, label: "入门" },
|
|
{ min: 1.5, max: 2.0, label: "初级" },
|
|
{ min: 2.0, max: 2.5, label: "初中级" },
|
|
{ min: 2.5, max: 3.0, label: "中级" },
|
|
{ min: 3.0, max: 3.5, label: "中高级" },
|
|
{ min: 3.5, max: 4.0, label: "高级" },
|
|
{ min: 4.0, max: 4.5, label: "高级竞技" },
|
|
{ min: 4.5, max: 5.1, label: "接近专业" },
|
|
];
|
|
|
|
function getLevel(rating: number) {
|
|
return NTRP_LEVELS.find((item) => rating >= item.min && rating < item.max)?.label || "入门";
|
|
}
|
|
|
|
export default function Rating() {
|
|
const [taskId, setTaskId] = useState<string | null>(null);
|
|
const currentQuery = trpc.rating.current.useQuery();
|
|
const historyQuery = trpc.rating.history.useQuery();
|
|
const refreshMineMutation = trpc.rating.refreshMine.useMutation({
|
|
onSuccess: (data) => {
|
|
setTaskId(data.taskId);
|
|
toast.success("NTRP 刷新任务已加入后台队列");
|
|
},
|
|
onError: (error) => toast.error(`NTRP 刷新失败: ${error.message}`),
|
|
});
|
|
const taskQuery = useBackgroundTask(taskId);
|
|
|
|
const currentRating = currentQuery.data?.rating || 1.5;
|
|
const latestSnapshot = currentQuery.data?.latestSnapshot as any;
|
|
const history = historyQuery.data ?? [];
|
|
|
|
const radarData = useMemo(() => {
|
|
const scores = latestSnapshot?.dimensionScores || {};
|
|
return [
|
|
{ dimension: "姿态", value: scores.poseAccuracy || 0 },
|
|
{ dimension: "一致性", value: scores.strokeConsistency || 0 },
|
|
{ dimension: "脚步", value: scores.footwork || 0 },
|
|
{ dimension: "流畅度", value: scores.fluidity || 0 },
|
|
{ dimension: "时机", value: scores.timing || 0 },
|
|
{ dimension: "比赛准备", value: scores.matchReadiness || 0 },
|
|
];
|
|
}, [latestSnapshot?.dimensionScores]);
|
|
|
|
const trendData = useMemo(
|
|
() => history.map((item: any) => ({
|
|
date: formatMonthDayShanghai(item.createdAt),
|
|
rating: item.rating,
|
|
})).reverse(),
|
|
[history],
|
|
);
|
|
|
|
if (currentQuery.isLoading || historyQuery.isLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-40 w-full" />
|
|
<Skeleton className="h-80 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_32%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">NTRP 评分系统</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
评分由历史训练、实时分析、录制归档与动作质量共同计算。每日零点后会自动异步刷新,当前用户也可以手动提交刷新任务。
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => refreshMineMutation.mutate()} disabled={refreshMineMutation.isPending} className="gap-2">
|
|
{refreshMineMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
刷新我的 NTRP
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{(taskQuery.data?.status === "queued" || taskQuery.data?.status === "running") ? (
|
|
<Alert>
|
|
<RefreshCw className="h-4 w-4" />
|
|
<AlertTitle>后台执行中</AlertTitle>
|
|
<AlertDescription>{taskQuery.data.message || "NTRP 刷新任务正在后台执行。"}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,360px)]">
|
|
<Card className="border-0 shadow-sm">
|
|
<CardContent className="pt-6">
|
|
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-3xl bg-emerald-500/10 px-5 py-4 text-4xl font-semibold text-emerald-700">
|
|
{currentRating.toFixed(1)}
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-semibold">{getLevel(currentRating)}</div>
|
|
<div className="mt-1 text-sm text-muted-foreground">最新综合评分</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
<Badge className="bg-emerald-500/10 text-emerald-700">
|
|
<Award className="mr-1 h-3.5 w-3.5" />
|
|
NTRP {currentRating.toFixed(1)}
|
|
</Badge>
|
|
{latestSnapshot?.triggerType ? <Badge variant="outline">来源 {latestSnapshot.triggerType}</Badge> : null}
|
|
{latestSnapshot?.createdAt ? (
|
|
<Badge variant="outline">
|
|
刷新于 {formatDateTimeShanghai(latestSnapshot.createdAt)}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
|
<div className="text-muted-foreground">训练日</div>
|
|
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.activeDays || 0}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
|
<div className="text-muted-foreground">有效动作</div>
|
|
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalEffectiveActions || 0}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
|
<div className="text-muted-foreground">实时分析</div>
|
|
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.liveSessions || 0}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
|
<div className="text-muted-foreground">PK 会话</div>
|
|
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalPk || 0}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-0 shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">评分维度</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{radarData.map((item) => (
|
|
<div key={item.dimension}>
|
|
<div className="mb-2 flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{item.dimension}</span>
|
|
<span className="font-medium">{Math.round(item.value)}</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-muted/70">
|
|
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${item.value}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<Card className="border-0 shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<TrendingUp className="h-4 w-4 text-primary" />
|
|
NTRP 趋势
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{trendData.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-14 text-center text-sm text-muted-foreground">
|
|
暂无评分趋势数据。
|
|
</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<AreaChart data={trendData}>
|
|
<defs>
|
|
<linearGradient id="rating-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.26} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
|
<YAxis domain={[1, 5]} tick={{ fontSize: 12 }} />
|
|
<Tooltip />
|
|
<Area type="monotone" dataKey="rating" stroke="#10b981" strokeWidth={2} fill="url(#rating-fill)" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-0 shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Radar className="h-4 w-4 text-primary" />
|
|
最新雷达图
|
|
</CardTitle>
|
|
<CardDescription>按最近一次 NTRP 快照展示维度得分。</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<RadarChart data={radarData}>
|
|
<PolarGrid />
|
|
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
|
|
<PolarRadiusAxis angle={90} domain={[0, 100]} />
|
|
<RadarChartShape dataKey="value" stroke="#10b981" fill="#10b981" fillOpacity={0.25} />
|
|
</RadarChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="border-0 shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">历史快照</CardTitle>
|
|
<CardDescription>这里展示异步评分任务生成的最新记录。</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{history.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
|
暂无历史快照。
|
|
</div>
|
|
) : (
|
|
history.map((item: any) => (
|
|
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
|
|
<Badge variant="outline">{item.triggerType}</Badge>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(item.createdAt)}</div>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1">
|
|
<Activity className="h-4 w-4" />
|
|
分析 {item.sourceSummary?.analyses || 0}
|
|
</span>
|
|
<span>实时 {item.sourceSummary?.liveSessions || 0}</span>
|
|
<span>动作 {item.sourceSummary?.totalEffectiveActions || 0}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|