289 行
12 KiB
TypeScript
289 行
12 KiB
TypeScript
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>
|
||
);
|
||
}
|