Checkpoint: Tennis Training Hub v1.0 - 完整功能版本:用户名登录、AI训练计划生成、MediaPipe视频姿势识别、击球统计、挥拍速度分析、NTRP自动评分系统、训练进度追踪、视频库管理、AI矫正建议
这个提交包含在:
669
client/src/pages/Analysis.tsx
普通文件
669
client/src/pages/Analysis.tsx
普通文件
@@ -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;
|
||||
}
|
||||
279
client/src/pages/Dashboard.tsx
普通文件
279
client/src/pages/Dashboard.tsx
普通文件
@@ -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
普通文件
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>
|
||||
);
|
||||
}
|
||||
215
client/src/pages/Progress.tsx
普通文件
215
client/src/pages/Progress.tsx
普通文件
@@ -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
普通文件
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>
|
||||
);
|
||||
}
|
||||
287
client/src/pages/Training.tsx
普通文件
287
client/src/pages/Training.tsx
普通文件
@@ -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
普通文件
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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户