文件
tennis-training-hub/client/src/pages/Training.tsx
2026-03-14 21:50:09 +08:00

289 行
12 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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" data-testid="training-title"></h1>
<p className="text-muted-foreground text-sm mt-1"></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>
</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
data-testid="training-generate-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" />...</>
) : (
<><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>
);
}