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

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

查看文件

@@ -0,0 +1,669 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Upload, Video, Loader2, Play, Pause, RotateCcw,
Zap, Target, Activity, TrendingUp, Eye
} from "lucide-react";
import { Streamdown } from "streamdown";
type AnalysisResult = {
overallScore: number;
shotCount: number;
avgSwingSpeed: number;
maxSwingSpeed: number;
totalMovementDistance: number;
strokeConsistency: number;
footworkScore: number;
fluidityScore: number;
poseMetrics: any;
detectedIssues: string[];
keyMoments: any[];
movementTrajectory: any[];
framesAnalyzed: number;
};
export default function Analysis() {
const { user } = useAuth();
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string>("");
const [exerciseType, setExerciseType] = useState("forehand");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);
const [corrections, setCorrections] = useState<string>("");
const [showSkeleton, setShowSkeleton] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const uploadMutation = trpc.video.upload.useMutation();
const saveMutation = trpc.analysis.save.useMutation({
onSuccess: () => {
utils.profile.stats.invalidate();
utils.analysis.list.invalidate();
utils.rating.current.invalidate();
utils.rating.history.invalidate();
},
});
const correctionMutation = trpc.analysis.getCorrections.useMutation();
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const ext = file.name.split(".").pop()?.toLowerCase();
if (!["mp4", "webm"].includes(ext || "")) {
toast.error("仅支持 MP4 和 WebM 格式");
return;
}
if (file.size > 100 * 1024 * 1024) {
toast.error("文件大小不能超过100MB");
return;
}
setVideoFile(file);
setVideoUrl(URL.createObjectURL(file));
setAnalysisResult(null);
setCorrections("");
};
const analyzeVideo = useCallback(async () => {
if (!videoRef.current || !canvasRef.current || !videoFile) return;
setIsAnalyzing(true);
setAnalysisProgress(0);
setShowSkeleton(true);
try {
// Load MediaPipe Pose
const { Pose } = await import("@mediapipe/pose");
const { drawConnectors, drawLandmarks } = await import("@mediapipe/drawing_utils");
const { POSE_CONNECTIONS } = await import("@mediapipe/pose");
const pose = new Pose({
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
});
pose.setOptions({
modelComplexity: 1,
smoothLandmarks: true,
enableSegmentation: false,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
// Analysis accumulators
let framesAnalyzed = 0;
let allPoseData: any[] = [];
let swingSpeedHistory: number[] = [];
let prevWristPos: { x: number; y: number } | null = null;
let shotCount = 0;
let prevWristSpeed = 0;
let isInSwing = false;
let movementTrajectory: { x: number; y: number; frame: number }[] = [];
let prevHipCenter: { x: number; y: number } | null = null;
let totalMovement = 0;
let keyMoments: { frame: number; type: string; description: string }[] = [];
let jointAnglesHistory: any[] = [];
// Pose callback
pose.onResults((results: any) => {
if (!results.poseLandmarks) return;
const landmarks = results.poseLandmarks;
framesAnalyzed++;
// Draw skeleton on canvas
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
if (showSkeleton) {
drawConnectors(ctx, landmarks, POSE_CONNECTIONS, { color: "#00FF00", lineWidth: 2 });
drawLandmarks(ctx, landmarks, { color: "#FF0000", lineWidth: 1, radius: 3 });
}
// Calculate joint angles
const angles = calculateJointAngles(landmarks);
jointAnglesHistory.push(angles);
// Wrist tracking for swing speed (right wrist = index 16)
const rightWrist = landmarks[16];
const leftWrist = landmarks[15];
const dominantWrist = exerciseType.includes("backhand") ? leftWrist : rightWrist;
if (dominantWrist && prevWristPos) {
const dx = (dominantWrist.x - prevWristPos.x) * canvas.width;
const dy = (dominantWrist.y - prevWristPos.y) * canvas.height;
const speed = Math.sqrt(dx * dx + dy * dy);
swingSpeedHistory.push(speed);
// Shot detection: speed spike above threshold
if (speed > 15 && prevWristSpeed < 15 && !isInSwing) {
isInSwing = true;
shotCount++;
keyMoments.push({
frame: framesAnalyzed,
type: "shot",
description: `${shotCount}次击球 - 挥拍速度: ${speed.toFixed(1)}px/frame`,
});
}
if (speed < 5 && isInSwing) {
isInSwing = false;
}
prevWristSpeed = speed;
}
if (dominantWrist) {
prevWristPos = { x: dominantWrist.x, y: dominantWrist.y };
}
// Body center tracking (hip midpoint)
const leftHip = landmarks[23];
const rightHip = landmarks[24];
if (leftHip && rightHip) {
const hipCenter = {
x: (leftHip.x + rightHip.x) / 2,
y: (leftHip.y + rightHip.y) / 2,
};
movementTrajectory.push({ ...hipCenter, frame: framesAnalyzed });
if (prevHipCenter) {
const dx = (hipCenter.x - prevHipCenter.x) * canvas.width;
const dy = (hipCenter.y - prevHipCenter.y) * canvas.height;
totalMovement += Math.sqrt(dx * dx + dy * dy);
}
prevHipCenter = hipCenter;
}
allPoseData.push(landmarks);
});
// Process video frames
const fps = 15; // Sample at 15fps for performance
const duration = video.duration;
const totalFrames = Math.floor(duration * fps);
let currentFrame = 0;
video.currentTime = 0;
await new Promise<void>((resolve) => {
const processFrame = async () => {
if (currentFrame >= totalFrames || video.currentTime >= duration) {
resolve();
return;
}
await pose.send({ image: video });
currentFrame++;
setAnalysisProgress(Math.round((currentFrame / totalFrames) * 100));
video.currentTime = currentFrame / fps;
video.onseeked = () => {
requestAnimationFrame(processFrame);
};
};
video.onseeked = () => processFrame();
video.currentTime = 0;
});
// Calculate final metrics
const avgSwingSpeed = swingSpeedHistory.length > 0
? swingSpeedHistory.reduce((a, b) => a + b, 0) / swingSpeedHistory.length : 0;
const maxSwingSpeed = swingSpeedHistory.length > 0
? Math.max(...swingSpeedHistory) : 0;
// Stroke consistency: std deviation of swing speeds during shots
const shotSpeeds = swingSpeedHistory.filter(s => s > 10);
const strokeConsistency = calculateConsistency(shotSpeeds);
// Footwork score based on movement patterns
const footworkScore = calculateFootworkScore(movementTrajectory, canvas.width, canvas.height);
// Fluidity score based on angle smoothness
const fluidityScore = calculateFluidityScore(jointAnglesHistory);
// Overall score
const overallScore = Math.round(
strokeConsistency * 0.25 +
footworkScore * 0.25 +
fluidityScore * 0.25 +
Math.min(100, avgSwingSpeed * 3) * 0.15 +
Math.min(100, shotCount * 10) * 0.10
);
// Detect issues
const detectedIssues = detectIssues(jointAnglesHistory, exerciseType, avgSwingSpeed, footworkScore);
const result: AnalysisResult = {
overallScore,
shotCount,
avgSwingSpeed: Math.round(avgSwingSpeed * 10) / 10,
maxSwingSpeed: Math.round(maxSwingSpeed * 10) / 10,
totalMovementDistance: Math.round(totalMovement),
strokeConsistency: Math.round(strokeConsistency),
footworkScore: Math.round(footworkScore),
fluidityScore: Math.round(fluidityScore),
poseMetrics: {
avgAngles: averageAngles(jointAnglesHistory),
frameCount: framesAnalyzed,
},
detectedIssues,
keyMoments,
movementTrajectory,
framesAnalyzed,
};
setAnalysisResult(result);
// Upload video and save analysis
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(",")[1];
try {
const { videoId } = await uploadMutation.mutateAsync({
title: `${exerciseType}_${new Date().toISOString().slice(0, 10)}`,
format: videoFile.name.split(".").pop() || "mp4",
fileSize: videoFile.size,
exerciseType,
fileBase64: base64,
});
await saveMutation.mutateAsync({
videoId,
...result,
});
toast.success("分析完成并已保存NTRP评分已自动更新。");
} catch (err: any) {
toast.error("保存失败: " + err.message);
}
};
reader.readAsDataURL(videoFile);
// Get AI corrections
correctionMutation.mutate({
poseMetrics: result.poseMetrics,
exerciseType,
detectedIssues: result.detectedIssues,
}, {
onSuccess: (data) => setCorrections(data.corrections as string),
});
pose.close();
} catch (err: any) {
toast.error("分析失败: " + err.message);
console.error(err);
} finally {
setIsAnalyzing(false);
}
}, [videoFile, exerciseType, showSkeleton]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">姿</h1>
<p className="text-muted-foreground text-sm mt-1">AI自动识别姿势并给出矫正建议</p>
</div>
{/* Upload section */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Upload className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={exerciseType} onValueChange={setExerciseType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="forehand"></SelectItem>
<SelectItem value="backhand"></SelectItem>
<SelectItem value="serve"></SelectItem>
<SelectItem value="volley"></SelectItem>
<SelectItem value="footwork"></SelectItem>
<SelectItem value="shadow"></SelectItem>
<SelectItem value="wall"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
ref={fileInputRef}
type="file"
accept="video/mp4,video/webm"
onChange={handleFileSelect}
className="h-10"
/>
</div>
</div>
{videoUrl && (
<div className="relative">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<video
ref={videoRef}
src={videoUrl}
className="w-full rounded-lg border bg-black"
controls
crossOrigin="anonymous"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSkeleton(!showSkeleton)}
className="text-xs gap-1"
>
<Eye className="h-3 w-3" />
{showSkeleton ? "隐藏骨骼" : "显示骨骼"}
</Button>
</div>
<canvas
ref={canvasRef}
className="w-full rounded-lg border bg-black"
/>
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<Button
onClick={analyzeVideo}
disabled={isAnalyzing}
className="gap-2"
>
{isAnalyzing ? (
<><Loader2 className="h-4 w-4 animate-spin" /> {analysisProgress}%</>
) : (
<><Play className="h-4 w-4" /></>
)}
</Button>
{isAnalyzing && (
<Progress value={analysisProgress} className="flex-1 h-2" />
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Analysis results */}
{analysisResult && (
<>
{/* Score overview */}
<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-4 pb-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-3xl font-bold text-primary">{analysisResult.overallScore}</p>
<p className="text-xs text-muted-foreground">/100</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground flex items-center gap-1"><Zap className="h-3 w-3" /></p>
<p className="text-3xl font-bold">{analysisResult.shotCount}</p>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground flex items-center gap-1"><Activity className="h-3 w-3" /></p>
<p className="text-3xl font-bold">{analysisResult.avgSwingSpeed}</p>
<p className="text-xs text-muted-foreground">px/</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3 w-3" /></p>
<p className="text-3xl font-bold">{analysisResult.totalMovementDistance}</p>
<p className="text-xs text-muted-foreground">px</p>
</CardContent>
</Card>
</div>
{/* Dimension scores */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ label: "击球一致性", value: analysisResult.strokeConsistency, color: "bg-blue-500" },
{ label: "脚步移动", value: analysisResult.footworkScore, color: "bg-green-500" },
{ label: "动作流畅性", value: analysisResult.fluidityScore, color: "bg-purple-500" },
{ label: "最大挥拍速度", value: Math.min(100, analysisResult.maxSwingSpeed * 3), color: "bg-orange-500" },
].map(item => (
<div key={item.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{item.label}</span>
<span className="font-medium">{Math.round(item.value)}/100</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${item.color}`}
style={{ width: `${Math.min(100, item.value)}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Detected issues */}
{analysisResult.detectedIssues.length > 0 && (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="h-4 w-4 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{analysisResult.detectedIssues.map((issue, i) => (
<div key={i} className="flex items-start gap-2 p-3 bg-orange-50 rounded-lg">
<span className="text-orange-500 mt-0.5 text-sm"></span>
<p className="text-sm">{issue}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Key moments */}
{analysisResult.keyMoments.length > 0 && (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{analysisResult.keyMoments.map((moment: any, i: number) => (
<div key={i} className="flex items-center gap-3 p-2 border rounded-lg">
<Badge variant="secondary" className="shrink-0"> {moment.frame}</Badge>
<span className="text-sm">{moment.description}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* AI Corrections */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
AI矫正建议
</CardTitle>
</CardHeader>
<CardContent>
{correctionMutation.isPending ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">AI正在生成矫正建议...</span>
</div>
) : corrections ? (
<div className="prose prose-sm max-w-none">
<Streamdown>{corrections}</Streamdown>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}
// ===== Helper functions =====
function calculateJointAngles(landmarks: any[]) {
const getAngle = (a: any, b: any, c: any) => {
const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
let angle = Math.abs(radians * 180 / Math.PI);
if (angle > 180) angle = 360 - angle;
return angle;
};
return {
rightElbow: getAngle(landmarks[12], landmarks[14], landmarks[16]),
leftElbow: getAngle(landmarks[11], landmarks[13], landmarks[15]),
rightShoulder: getAngle(landmarks[14], landmarks[12], landmarks[24]),
leftShoulder: getAngle(landmarks[13], landmarks[11], landmarks[23]),
rightKnee: getAngle(landmarks[24], landmarks[26], landmarks[28]),
leftKnee: getAngle(landmarks[23], landmarks[25], landmarks[27]),
rightHip: getAngle(landmarks[12], landmarks[24], landmarks[26]),
leftHip: getAngle(landmarks[11], landmarks[23], landmarks[25]),
torsoLean: getAngle(landmarks[11], landmarks[23], { x: landmarks[23].x, y: 0 }),
};
}
function calculateConsistency(speeds: number[]): number {
if (speeds.length < 2) return 50;
const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const variance = speeds.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / speeds.length;
const cv = Math.sqrt(variance) / (mean || 1);
return Math.max(0, Math.min(100, 100 - cv * 100));
}
function calculateFootworkScore(trajectory: any[], width: number, height: number): number {
if (trajectory.length < 10) return 50;
// Score based on movement variety and smoothness
let directionChanges = 0;
let totalDist = 0;
for (let i = 2; i < trajectory.length; i++) {
const dx1 = trajectory[i - 1].x - trajectory[i - 2].x;
const dy1 = trajectory[i - 1].y - trajectory[i - 2].y;
const dx2 = trajectory[i].x - trajectory[i - 1].x;
const dy2 = trajectory[i].y - trajectory[i - 1].y;
if ((dx1 * dx2 + dy1 * dy2) < 0) directionChanges++;
totalDist += Math.sqrt(dx2 * dx2 + dy2 * dy2);
}
const changeRate = directionChanges / trajectory.length;
const movementRange = totalDist * 1000;
return Math.min(100, Math.max(20, changeRate * 200 + movementRange * 0.5));
}
function calculateFluidityScore(anglesHistory: any[]): number {
if (anglesHistory.length < 3) return 50;
let totalJerkiness = 0;
const keys = Object.keys(anglesHistory[0] || {});
for (let i = 2; i < anglesHistory.length; i++) {
for (const key of keys) {
const a0 = anglesHistory[i - 2][key] || 0;
const a1 = anglesHistory[i - 1][key] || 0;
const a2 = anglesHistory[i][key] || 0;
const jerk = Math.abs((a2 - a1) - (a1 - a0));
totalJerkiness += jerk;
}
}
const avgJerk = totalJerkiness / ((anglesHistory.length - 2) * keys.length);
return Math.max(0, Math.min(100, 100 - avgJerk * 2));
}
function detectIssues(anglesHistory: any[], exerciseType: string, avgSpeed: number, footworkScore: number): string[] {
const issues: string[] = [];
if (anglesHistory.length === 0) return issues;
const avgAngles = averageAngles(anglesHistory);
// Check elbow angle for strokes
if (exerciseType === "forehand" || exerciseType === "shadow") {
if (avgAngles.rightElbow < 90) issues.push("正手击球时肘部弯曲过大,建议保持手臂更加伸展");
if (avgAngles.rightElbow > 170) issues.push("正手击球时手臂过于僵直,建议略微弯曲肘部");
}
if (exerciseType === "backhand") {
if (avgAngles.leftElbow < 80) issues.push("反手击球时肘部弯曲过大");
}
if (exerciseType === "serve") {
if (avgAngles.rightShoulder < 140) issues.push("发球时肩部旋转不够充分,需要更大的肩部打开角度");
}
// Check knee bend
if (avgAngles.rightKnee > 170 && avgAngles.leftKnee > 170) {
issues.push("膝盖弯曲不足,建议保持适当的屈膝姿势以提高稳定性");
}
// Check torso
if (avgAngles.torsoLean < 70) {
issues.push("身体前倾过多,注意保持上身直立");
}
// Speed check
if (avgSpeed < 5) {
issues.push("挥拍速度偏慢,建议加快挥拍节奏");
}
// Footwork check
if (footworkScore < 40) {
issues.push("脚步移动不够活跃,建议增加脚步训练");
}
return issues;
}
function averageAngles(anglesHistory: any[]) {
if (anglesHistory.length === 0) return {};
const keys = Object.keys(anglesHistory[0] || {});
const avg: Record<string, number> = {};
for (const key of keys) {
avg[key] = Math.round(
anglesHistory.reduce((sum, a) => sum + (a[key] || 0), 0) / anglesHistory.length
);
}
return avg;
}

查看文件

@@ -0,0 +1,279 @@
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">
{/* Welcome header */}
<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">
{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 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>AI分析</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">AI定制个人训练方案</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">MediaPipe AI姿势识别</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>
);
}

查看文件

@@ -1,31 +1,150 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { getLoginUrl } from "@/const";
import { Streamdown } from 'streamdown';
import { useLocation, Redirect } from "wouter";
import {
Target, Video, Award, TrendingUp, Zap, Activity,
ChevronRight, Footprints, BarChart3
} from "lucide-react";
/**
* All content in this page are only for example, replace with your own feature implementation
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
*/
export default function Home() {
// The userAuth hooks provides authentication state
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
let { user, loading, error, isAuthenticated, logout } = useAuth();
const { user, loading, isAuthenticated } = useAuth();
const [, setLocation] = useLocation();
// If theme is switchable in App.tsx, we can implement theme toggling like this:
// const { theme, toggleTheme } = useTheme();
if (loading) return null;
if (isAuthenticated) return <Redirect to="/dashboard" />;
return (
<div className="min-h-screen flex flex-col">
<main>
{/* Example: lucide-react for icons */}
<Loader2 className="animate-spin" />
Example Page
{/* Example: Streamdown for markdown rendering */}
<Streamdown>Any **markdown** content</Streamdown>
<Button variant="default">Example Button</Button>
</main>
<div className="min-h-screen bg-gradient-to-b from-green-50 via-background to-emerald-50/30">
{/* Hero */}
<header className="container py-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-6 w-6 text-primary" />
<span className="font-bold text-lg tracking-tight">Tennis Training Hub</span>
</div>
<Button onClick={() => setLocation("/login")} variant="default" size="sm">
使
</Button>
</header>
<section className="container py-16 md:py-24">
<div className="max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6">
<Zap className="h-3.5 w-3.5" />
AI驱动的网球训练助手
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
<span className="text-primary block mt-1"></span>
</h1>
<p className="text-lg text-muted-foreground mt-6 max-w-xl mx-auto leading-relaxed">
AI姿势识别和智能训练计划
</p>
<div className="flex items-center justify-center gap-3 mt-8">
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2 h-12 px-6">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{/* Features */}
<section className="container py-16">
<h2 className="text-2xl font-bold text-center mb-12"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{[
{
icon: Video,
title: "AI姿势识别",
desc: "基于MediaPipe的浏览器端实时姿势分析,识别33个身体关键点,精准评估挥拍动作",
color: "bg-blue-50 text-blue-600",
},
{
icon: Target,
title: "智能训练计划",
desc: "根据您的水平和分析结果,AI自动生成和调整个性化训练方案,只需球拍即可在家训练",
color: "bg-green-50 text-green-600",
},
{
icon: Award,
title: "NTRP自动评分",
desc: "基于美国网球协会标准,从5个维度综合评估您的技术水平,自动更新评分",
color: "bg-purple-50 text-purple-600",
},
{
icon: Zap,
title: "击球统计分析",
desc: "自动检测击球次数、挥拍速度、击球一致性,量化每次训练效果",
color: "bg-orange-50 text-orange-600",
},
{
icon: Footprints,
title: "运动轨迹追踪",
desc: "记录身体重心移动轨迹,分析脚步移动模式,提升步法灵活性",
color: "bg-teal-50 text-teal-600",
},
{
icon: TrendingUp,
title: "进度可视化",
desc: "直观展示训练历史、能力提升趋势和评分变化,激励持续进步",
color: "bg-indigo-50 text-indigo-600",
},
].map((feature) => (
<div key={feature.title} className="p-6 rounded-2xl border bg-card hover:shadow-md transition-shadow">
<div className={`h-12 w-12 rounded-xl ${feature.color} flex items-center justify-center mb-4`}>
<feature.icon className="h-6 w-6" />
</div>
<h3 className="font-semibold text-base mb-2">{feature.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{feature.desc}</p>
</div>
))}
</div>
</section>
{/* How it works */}
<section className="container py-16">
<h2 className="text-2xl font-bold text-center mb-12">使</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 max-w-4xl mx-auto">
{[
{ step: "1", title: "输入用户名", desc: "无需注册,输入用户名即可开始" },
{ step: "2", title: "生成训练计划", desc: "选择水平,AI生成个性化方案" },
{ step: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" },
{ step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" },
].map((item) => (
<div key={item.step} className="text-center">
<div className="h-12 w-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-bold mx-auto mb-3">
{item.step}
</div>
<h3 className="font-semibold text-sm mb-1">{item.title}</h3>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="container py-16">
<div className="max-w-2xl mx-auto text-center p-8 rounded-2xl bg-primary/5">
<h2 className="text-2xl font-bold mb-3"></h2>
<p className="text-muted-foreground mb-6"></p>
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</section>
{/* Footer */}
<footer className="container py-8 border-t">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Target className="h-4 w-4" />
<span>Tennis Training Hub</span>
</div>
<span>AI驱动的在家网球训练助手</span>
</div>
</footer>
</div>
);
}

102
client/src/pages/Login.tsx 普通文件
查看文件

@@ -0,0 +1,102 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { trpc } from "@/lib/trpc";
import { useLocation } from "wouter";
import { toast } from "sonner";
import { Target, Loader2 } from "lucide-react";
export default function Login() {
const [username, setUsername] = useState("");
const [, setLocation] = useLocation();
const loginMutation = trpc.auth.loginWithUsername.useMutation({
onSuccess: (data) => {
toast.success(data.isNew ? `欢迎加入,${data.user.name}` : `欢迎回来,${data.user.name}`);
setLocation("/dashboard");
},
onError: (err) => {
toast.error("登录失败: " + err.message);
},
});
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim()) {
toast.error("请输入用户名");
return;
}
loginMutation.mutate({ username: username.trim() });
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-background to-emerald-50 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
<Target className="w-8 h-8 text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight">Tennis Training Hub</h1>
<p className="text-muted-foreground mt-2">AI驱动的在家网球训练助手</p>
</div>
<Card className="border-0 shadow-xl">
<CardHeader className="text-center pb-2">
<CardTitle className="text-xl"></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Input
type="text"
placeholder="请输入您的用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-12 text-base"
autoFocus
maxLength={64}
/>
</div>
<Button
type="submit"
className="w-full h-12 text-base font-medium"
disabled={loginMutation.isPending || !username.trim()}
>
{loginMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"进入训练"
)}
</Button>
</form>
<div className="mt-6 pt-4 border-t">
<div className="grid grid-cols-3 gap-3 text-center text-xs text-muted-foreground">
<div className="flex flex-col items-center gap-1">
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">AI</div>
<span>姿</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">📊</div>
<span></span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">🎯</div>
<span>NTRP评分</span>
</div>
</div>
</div>
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground mt-6">
使
</p>
</div>
</div>
);
}

查看文件

@@ -0,0 +1,215 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target } from "lucide-react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
LineChart, Line, Legend
} from "recharts";
import { useLocation } from "wouter";
export default function Progress() {
const { user } = useAuth();
const { data: records, isLoading } = trpc.record.list.useQuery({ limit: 100 });
const { data: analyses } = trpc.analysis.list.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
// Aggregate data by date for charts
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
(records || []).forEach((r: any) => {
const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
existing.sessions++;
existing.minutes += r.durationMinutes || 0;
if (r.poseScore) existing.scores.push(r.poseScore);
dateMap.set(date, existing);
});
const chartData = Array.from(dateMap.values()).map(d => ({
...d,
avgScore: d.scores.length > 0 ? Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length) : 0,
}));
// Analysis score trend
const scoreTrend = (analyses || []).map((a: any) => ({
date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
overall: Math.round(a.overallScore || 0),
consistency: Math.round(a.strokeConsistency || 0),
footwork: Math.round(a.footworkScore || 0),
fluidity: Math.round(a.fluidityScore || 0),
}));
const completedRecords = (records || []).filter((r: any) => r.completed === 1);
const totalMinutes = (records || []).reduce((sum: number, r: any) => sum + (r.durationMinutes || 0), 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm mt-1"></p>
</div>
{/* Summary stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Activity className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{stats?.totalSessions || 0}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{totalMinutes}<span className="text-sm font-normal ml-1"></span></p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<CheckCircle2 className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{completedRecords.length}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Target className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{analyses?.length || 0}<span className="text-sm font-normal ml-1"></span></p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Training frequency chart */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="sessions" fill="oklch(0.55 0.16 145)" radius={[4, 4, 0, 0]} name="训练次数" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Calendar className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Score improvement trend */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
{scoreTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={scoreTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="overall" stroke="oklch(0.55 0.16 145)" strokeWidth={2} name="综合" dot={{ r: 3 }} />
<Line type="monotone" dataKey="consistency" stroke="#3b82f6" strokeWidth={1.5} name="一致性" dot={{ r: 2 }} />
<Line type="monotone" dataKey="footwork" stroke="#f59e0b" strokeWidth={1.5} name="脚步" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Recent records */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{(records?.length || 0) > 0 ? (
<div className="space-y-2">
{(records || []).slice(0, 20).map((record: any) => (
<div key={record.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 flex items-center justify-center ${
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
}`}>
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-medium">{record.exerciseName}</p>
<p className="text-xs text-muted-foreground">
{new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{record.poseScore && (
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}</Badge>
)}
{record.completed ? (
<Badge className="bg-green-100 text-green-700 text-xs"></Badge>
) : (
<Badge variant="outline" className="text-xs"></Badge>
)}
</div>
</div>
))}
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
<Activity className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
<Button variant="link" size="sm" onClick={() => setLocation("/training")} className="mt-2">
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

230
client/src/pages/Rating.tsx 普通文件
查看文件

@@ -0,0 +1,230 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react";
import {
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis,
PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis,
CartesianGrid, Tooltip, Legend
} from "recharts";
const NTRP_LEVELS = [
{ min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" },
{ min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" },
{ min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" },
{ min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" },
{ min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" },
{ min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" },
{ min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" },
{ min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" },
];
function getNTRPLevel(rating: number) {
return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0];
}
export default function Rating() {
const { user } = useAuth();
const { data: ratingData } = trpc.rating.current.useQuery();
const { data: history, isLoading } = trpc.rating.history.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const currentRating = ratingData?.rating || 1.5;
const level = getNTRPLevel(currentRating);
// Get latest dimension scores
const latestWithDimensions = history?.find((h: any) => h.dimensionScores);
const dimensions = (latestWithDimensions as any)?.dimensionScores || {};
const radarData = [
{ dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 },
{ dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 },
{ dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 },
{ dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 },
{ dimension: "力量", value: dimensions.power || 0, fullMark: 100 },
];
const trendData = (history || []).map((h: any) => ({
date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: h.rating,
}));
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-60 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系统</h1>
<p className="text-muted-foreground text-sm mt-1"></p>
</div>
{/* Current rating card */}
<Card className="border-0 shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
<div className="flex items-center gap-4">
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center">
<span className="text-3xl font-bold text-primary">{currentRating.toFixed(1)}</span>
</div>
<div>
<h2 className="text-xl font-bold">{level.label}</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-md">{level.desc}</p>
<div className="flex items-center gap-2 mt-2">
<Award className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">NTRP {currentRating.toFixed(1)}</span>
</div>
</div>
</div>
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Radar chart */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{Object.keys(dimensions).length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 10 }} />
<Radar
name="能力值"
dataKey="value"
stroke="oklch(0.55 0.16 145)"
fill="oklch(0.55 0.16 145)"
fillOpacity={0.3}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Target className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Rating trend */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription>NTRP评分随时间的变化</CardDescription>
</CardHeader>
<CardContent>
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={trendData}>
<defs>
<linearGradient id="ratingGrad" 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(#ratingGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Dimension details */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<CardDescription>NTRP评分由以下五个维度加权计算</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{[
{ icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy },
{ icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency },
{ icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork },
{ icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity },
{ icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power },
].map(item => (
<div key={item.label} className="p-4 rounded-xl border bg-card">
<div className="flex items-center gap-2 mb-2">
<item.icon className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{item.label}</span>
</div>
<p className="text-2xl font-bold">{item.value ? Math.round(item.value) : "--"}</p>
<p className="text-xs text-muted-foreground mt-1"> {item.weight}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* NTRP level reference */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">NTRP等级参考</CardTitle>
<CardDescription>(USTA)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{NTRP_LEVELS.map(l => (
<div
key={l.label}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
currentRating >= l.min && currentRating < l.max
? "bg-primary/5 border border-primary/20"
: "hover:bg-muted/50"
}`}
>
<Badge className={`${l.color} border shrink-0`}>
{l.min.toFixed(1)}-{l.max.toFixed(1)}
</Badge>
<div>
<span className="text-sm font-medium">{l.label}</span>
<p className="text-xs text-muted-foreground">{l.desc}</p>
</div>
{currentRating >= l.min && currentRating < l.max && (
<Badge variant="default" className="ml-auto shrink-0"></Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

查看文件

@@ -0,0 +1,287 @@
import { useState, useMemo } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import {
Target, Loader2, CheckCircle2, Circle, Clock, Dumbbell,
RefreshCw, Footprints, Hand, ArrowRight, Sparkles
} from "lucide-react";
const categoryIcons: Record<string, React.ReactNode> = {
"影子挥拍": <Hand className="h-4 w-4" />,
"脚步移动": <Footprints className="h-4 w-4" />,
"体能训练": <Dumbbell className="h-4 w-4" />,
"墙壁练习": <Target className="h-4 w-4" />,
};
const categoryColors: Record<string, string> = {
"影子挥拍": "bg-blue-50 text-blue-700 border-blue-200",
"脚步移动": "bg-green-50 text-green-700 border-green-200",
"体能训练": "bg-orange-50 text-orange-700 border-orange-200",
"墙壁练习": "bg-purple-50 text-purple-700 border-purple-200",
};
type Exercise = {
day: number;
name: string;
category: string;
duration: number;
description: string;
tips: string;
sets: number;
reps: number;
};
export default function Training() {
const { user } = useAuth();
const [skillLevel, setSkillLevel] = useState<"beginner" | "intermediate" | "advanced">("beginner");
const [durationDays, setDurationDays] = useState(7);
const [selectedDay, setSelectedDay] = useState(1);
const utils = trpc.useUtils();
const { data: activePlan, isLoading: planLoading } = trpc.plan.active.useQuery();
const generateMutation = trpc.plan.generate.useMutation({
onSuccess: () => {
toast.success("训练计划已生成!");
utils.plan.active.invalidate();
utils.plan.list.invalidate();
},
onError: (err) => toast.error("生成失败: " + err.message),
});
const adjustMutation = trpc.plan.adjust.useMutation({
onSuccess: (data) => {
toast.success("训练计划已调整!");
utils.plan.active.invalidate();
if (data.adjustmentNotes) toast.info("调整说明: " + data.adjustmentNotes);
},
onError: (err) => toast.error("调整失败: " + err.message),
});
const recordMutation = trpc.record.create.useMutation({
onSuccess: () => toast.success("训练记录已创建"),
});
const completeMutation = trpc.record.complete.useMutation({
onSuccess: () => {
toast.success("训练已完成!");
utils.profile.stats.invalidate();
},
});
const exercises = useMemo(() => {
if (!activePlan?.exercises) return [];
return (activePlan.exercises as Exercise[]).filter(e => e.day === selectedDay);
}, [activePlan, selectedDay]);
const totalDays = activePlan?.durationDays || 7;
if (planLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-60 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">AI为您定制的在家网球训练方案</p>
</div>
</div>
{!activePlan ? (
/* Generate new plan */
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription>
AI将生成个性化的在家训练方案
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={skillLevel} onValueChange={(v: any) => setSkillLevel(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner"> - </SelectItem>
<SelectItem value="intermediate"> - </SelectItem>
<SelectItem value="advanced"> - </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={String(durationDays)} onValueChange={(v) => setDurationDays(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3</SelectItem>
<SelectItem value="7">7</SelectItem>
<SelectItem value="14">14</SelectItem>
<SelectItem value="30">30</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={() => generateMutation.mutate({ skillLevel, durationDays })}
disabled={generateMutation.isPending}
className="w-full sm:w-auto gap-2"
>
{generateMutation.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />AI生成中...</>
) : (
<><Sparkles className="h-4 w-4" /></>
)}
</Button>
</CardContent>
</Card>
) : (
/* Active plan display */
<>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{activePlan.title}</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"}
</Badge>
<span>{activePlan.durationDays}</span>
{activePlan.version > 1 && (
<Badge variant="outline" className="text-xs">v{activePlan.version} </Badge>
)}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => adjustMutation.mutate({ planId: activePlan.id })}
disabled={adjustMutation.isPending}
className="gap-1"
>
{adjustMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
{activePlan.adjustmentNotes && (
<div className="mt-3 p-3 bg-primary/5 rounded-lg text-sm text-primary">
<strong></strong>{activePlan.adjustmentNotes}
</div>
)}
</CardHeader>
<CardContent>
{/* Day selector */}
<div className="flex gap-2 overflow-x-auto pb-2 mb-4">
{Array.from({ length: totalDays }, (_, i) => i + 1).map(day => (
<button
key={day}
onClick={() => setSelectedDay(day)}
className={`shrink-0 w-10 h-10 rounded-xl text-sm font-medium transition-all ${
selectedDay === day
? "bg-primary text-primary-foreground shadow-md"
: "bg-muted hover:bg-accent"
}`}
>
{day}
</button>
))}
</div>
<h3 className="font-semibold mb-3"> {selectedDay} </h3>
{exercises.length > 0 ? (
<div className="space-y-3">
{exercises.map((ex, idx) => (
<div key={idx} className="border rounded-xl p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shrink-0 ${
categoryColors[ex.category] || "bg-gray-50 text-gray-700"
}`}>
{categoryIcons[ex.category] || <Target className="h-4 w-4" />}
</div>
<div>
<h4 className="font-medium text-sm">{ex.name}</h4>
<p className="text-xs text-muted-foreground mt-1">{ex.description}</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />{ex.duration}
</span>
<span>{ex.sets} × {ex.reps}</span>
</div>
{ex.tips && (
<p className="text-xs text-primary mt-2 bg-primary/5 rounded-md px-2 py-1">
💡 {ex.tips}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => {
recordMutation.mutate({
planId: activePlan.id,
exerciseName: ex.name,
durationMinutes: ex.duration,
});
}}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
<p></p>
</div>
)}
</CardContent>
</Card>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
generateMutation.mutate({ skillLevel, durationDays });
}}
disabled={generateMutation.isPending}
className="gap-2"
>
<Sparkles className="h-4 w-4" />
</Button>
</div>
</>
)}
</div>
);
}

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>
);
}