Checkpoint: Tennis Training Hub v1.0 - 完整功能版本:用户名登录、AI训练计划生成、MediaPipe视频姿势识别、击球统计、挥拍速度分析、NTRP自动评分系统、训练进度追踪、视频库管理、AI矫正建议

这个提交包含在:
Manus
2026-03-14 07:41:43 -04:00
父节点 00d6319ffb
当前提交 36907d1110
修改 29 个文件,包含 4870 行新增228 行删除

150
client/src/pages/Videos.tsx 普通文件
查看文件

@@ -0,0 +1,150 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Video, Play, BarChart3, Clock, Zap, ChevronRight, FileVideo } from "lucide-react";
import { useLocation } from "wouter";
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" },
analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" },
completed: { label: "已完成", color: "bg-green-100 text-green-700" },
failed: { label: "失败", color: "bg-red-100 text-red-700" },
};
const exerciseTypeMap: Record<string, string> = {
forehand: "正手挥拍",
backhand: "反手挥拍",
serve: "发球",
volley: "截击",
footwork: "脚步移动",
shadow: "影子挥拍",
wall: "墙壁练习",
};
export default function Videos() {
const { user } = useAuth();
const { data: videos, isLoading } = trpc.video.list.useQuery();
const { data: analyses } = trpc.analysis.list.useQuery();
const [, setLocation] = useLocation();
const getAnalysis = (videoId: number) => {
return analyses?.find((a: any) => a.videoId === videoId);
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm mt-1">
· {videos?.length || 0}
</p>
</div>
<Button onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
</div>
{(!videos || videos.length === 0) ? (
<Card className="border-0 shadow-sm">
<CardContent className="py-16 text-center">
<FileVideo className="h-12 w-12 mx-auto mb-4 text-muted-foreground/30" />
<h3 className="font-semibold text-lg mb-2"></h3>
<p className="text-muted-foreground text-sm mb-4">AI将自动分析姿势并给出建议</p>
<Button onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{videos.map((video: any) => {
const analysis = getAnalysis(video.id);
const status = statusMap[video.analysisStatus] || statusMap.pending;
return (
<Card key={video.id} className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Thumbnail / icon */}
<div className="h-20 w-28 rounded-lg bg-black/5 flex items-center justify-center shrink-0 overflow-hidden">
{video.url ? (
<video src={video.url} className="h-full w-full object-cover" muted preload="metadata" />
) : (
<Play className="h-6 w-6 text-muted-foreground/40" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-medium text-sm truncate">{video.title}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge className={`${status.color} border text-xs`}>{status.label}</Badge>
{video.exerciseType && (
<Badge variant="outline" className="text-xs">
{exerciseTypeMap[video.exerciseType] || video.exerciseType}
</Badge>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(video.createdAt).toLocaleDateString("zh-CN")}
</span>
<span className="text-xs text-muted-foreground">
{(video.fileSize / 1024 / 1024).toFixed(1)}MB
</span>
</div>
</div>
</div>
{/* Analysis summary */}
{analysis && (
<div className="flex items-center gap-4 mt-3 text-xs">
<div className="flex items-center gap-1">
<BarChart3 className="h-3 w-3 text-primary" />
<span className="font-medium">{Math.round(analysis.overallScore || 0)}</span>
</div>
{(analysis.shotCount ?? 0) > 0 && (
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-orange-500" />
<span>{analysis.shotCount}</span>
</div>
)}
{(analysis.avgSwingSpeed ?? 0) > 0 && (
<div className="flex items-center gap-1">
{(analysis.avgSwingSpeed ?? 0).toFixed(1)}
</div>
)}
{(analysis.strokeConsistency ?? 0) > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
{Math.round(analysis.strokeConsistency ?? 0)}%
</div>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}