279 行
12 KiB
TypeScript
279 行
12 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
||
import { trpc } from "@/lib/trpc";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
Target, Video, Activity, TrendingUp, Award, Clock,
|
||
Zap, BarChart3, ChevronRight
|
||
} from "lucide-react";
|
||
import { useLocation } from "wouter";
|
||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
|
||
|
||
function NTRPBadge({ rating }: { rating: number }) {
|
||
let level = "初学者";
|
||
let color = "bg-gray-100 text-gray-700";
|
||
if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
|
||
else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
|
||
else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
|
||
else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
|
||
else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
|
||
return (
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
||
NTRP {rating.toFixed(1)} · {level}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function Dashboard() {
|
||
const { user } = useAuth();
|
||
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
|
||
const [, setLocation] = useLocation();
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<Skeleton className="h-32 w-full" />
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ratingData = stats?.ratingHistory?.map((r: any) => ({
|
||
date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||
rating: r.rating,
|
||
...((r.dimensionScores as any) || {}),
|
||
})) || [];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight" data-testid="dashboard-title">
|
||
当前用户:{user?.name || "未命名用户"}
|
||
</h1>
|
||
<div className="flex items-center gap-3 mt-2">
|
||
<NTRPBadge rating={stats?.ntrpRating || 1.5} />
|
||
<span className="text-sm text-muted-foreground">
|
||
已完成 {stats?.totalSessions || 0} 次训练
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
|
||
<Target className="h-4 w-4" />
|
||
训练计划
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
|
||
<Video className="h-4 w-4" />
|
||
视频分析
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats cards */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
|
||
<CardContent className="pt-5 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">NTRP评分</p>
|
||
<p className="text-2xl font-bold text-primary mt-1">
|
||
{(stats?.ntrpRating || 1.5).toFixed(1)}
|
||
</p>
|
||
</div>
|
||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||
<Award className="h-5 w-5 text-primary" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-0 shadow-sm">
|
||
<CardContent className="pt-5 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">训练次数</p>
|
||
<p className="text-2xl font-bold mt-1">{stats?.totalSessions || 0}</p>
|
||
</div>
|
||
<div className="h-10 w-10 rounded-xl bg-blue-50 flex items-center justify-center">
|
||
<Activity className="h-5 w-5 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-0 shadow-sm">
|
||
<CardContent className="pt-5 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">训练时长</p>
|
||
<p className="text-2xl font-bold mt-1">{stats?.totalMinutes || 0}<span className="text-sm font-normal text-muted-foreground ml-1">分钟</span></p>
|
||
</div>
|
||
<div className="h-10 w-10 rounded-xl bg-orange-50 flex items-center justify-center">
|
||
<Clock className="h-5 w-5 text-orange-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-0 shadow-sm">
|
||
<CardContent className="pt-5 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">总击球数</p>
|
||
<p className="text-2xl font-bold mt-1">{stats?.totalShots || 0}</p>
|
||
</div>
|
||
<div className="h-10 w-10 rounded-xl bg-purple-50 flex items-center justify-center">
|
||
<Zap className="h-5 w-5 text-purple-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Rating trend chart */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||
<TrendingUp className="h-4 w-4 text-primary" />
|
||
NTRP评分趋势
|
||
</CardTitle>
|
||
<Button variant="ghost" size="sm" onClick={() => setLocation("/rating")} className="text-xs gap-1">
|
||
查看详情 <ChevronRight className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{ratingData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<AreaChart data={ratingData}>
|
||
<defs>
|
||
<linearGradient id="ratingGradient" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
|
||
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
|
||
<Tooltip />
|
||
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGradient)" strokeWidth={2} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||
<div className="text-center">
|
||
<BarChart3 className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||
<p>完成视频分析后将显示评分趋势</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Recent analyses */}
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||
<Video className="h-4 w-4 text-primary" />
|
||
最近分析
|
||
</CardTitle>
|
||
<Button variant="ghost" size="sm" onClick={() => setLocation("/videos")} className="text-xs gap-1">
|
||
查看全部 <ChevronRight className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{(stats?.recentAnalyses?.length || 0) > 0 ? (
|
||
<div className="space-y-3">
|
||
{stats!.recentAnalyses.slice(0, 4).map((a: any) => (
|
||
<div key={a.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-8 w-8 rounded-lg bg-primary/5 flex items-center justify-center text-xs font-bold text-primary">
|
||
{Math.round(a.overallScore || 0)}
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium">{a.exerciseType || "综合分析"}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
|
||
{a.shotCount ? ` · ${a.shotCount}次击球` : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Progress value={a.overallScore || 0} className="w-16 h-1.5" />
|
||
<span className="text-xs text-muted-foreground">{Math.round(a.overallScore || 0)}分</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||
<div className="text-center">
|
||
<Video className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||
<p>上传训练视频后可查看分析结果</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Quick actions */}
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-base font-semibold">常用入口</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<button
|
||
onClick={() => setLocation("/training")}
|
||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||
>
|
||
<div className="h-10 w-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
|
||
<Target className="h-5 w-5 text-green-700" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-sm">生成训练计划</p>
|
||
<p className="text-xs text-muted-foreground">按当前设置生成训练安排</p>
|
||
</div>
|
||
</button>
|
||
<button
|
||
onClick={() => setLocation("/analysis")}
|
||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||
>
|
||
<div className="h-10 w-10 rounded-xl bg-blue-100 flex items-center justify-center shrink-0">
|
||
<Video className="h-5 w-5 text-blue-700" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-sm">上传视频分析</p>
|
||
<p className="text-xs text-muted-foreground">导入视频并查看姿势分析</p>
|
||
</div>
|
||
</button>
|
||
<button
|
||
onClick={() => setLocation("/rating")}
|
||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||
>
|
||
<div className="h-10 w-10 rounded-xl bg-purple-100 flex items-center justify-center shrink-0">
|
||
<Award className="h-5 w-5 text-purple-700" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-sm">查看NTRP评分</p>
|
||
<p className="text-xs text-muted-foreground">查看评分记录和维度结果</p>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|