文件
tennis-training-hub/client/src/pages/Training.tsx
2026-04-07 11:00:03 +08:00

1244 行
54 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { formatDateTimeShanghai } from "@/lib/time";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
Activity,
ArrowRight,
ChevronLeft,
ChevronRight,
CheckCircle2,
Clock,
Dumbbell,
ExternalLink,
Flame,
Footprints,
Hand,
ListTodo,
Loader2,
RefreshCw,
Ruler,
Scale,
Sparkles,
Target,
TestTube2,
Trophy,
} from "lucide-react";
const SCORE_OPTIONS = [
{ value: "1", label: "1 分", description: "明显偏弱" },
{ value: "2", label: "2 分", description: "偏弱" },
{ value: "3", label: "3 分", description: "基础达标" },
{ value: "4", label: "4 分", description: "较强" },
{ value: "5", label: "5 分", description: "优势项" },
];
const NTRP_OPTIONS = ["1.0", "1.5", "2.0", "2.5", "3.0", "3.5", "4.0", "4.5", "5.0"];
const REQUIRED_PROFILE_KEYS = [
"heightCm",
"weightKg",
"sprintSpeedScore",
"explosivePowerScore",
"agilityScore",
"enduranceScore",
"flexibilityScore",
"coreStabilityScore",
"shoulderMobilityScore",
"hipMobilityScore",
] as const;
type AssessmentScoreGuide = {
score: "1" | "2" | "3" | "4" | "5";
range: string;
note: string;
};
type AssessmentFieldConfig = {
key: keyof Pick<
TrainingProfileDraft,
| "sprintSpeedScore"
| "explosivePowerScore"
| "agilityScore"
| "enduranceScore"
| "flexibilityScore"
| "coreStabilityScore"
| "shoulderMobilityScore"
| "hipMobilityScore"
>;
label: string;
method: string;
hint: string;
benchmarkLabel: string;
benchmarkNote: string;
scoreGuide: AssessmentScoreGuide[];
};
const ASSESSMENT_FIELDS = [
{
key: "sprintSpeedScore",
label: "速度",
method: "20m 或 30m 冲刺,记录自己的最佳一次。",
hint: "根据实际表现按 1-5 分录入;1=启动慢,5=启动速度明显有优势。",
benchmarkLabel: "20 米冲刺参考",
benchmarkNote: "适用于成人业余训练的自测换算,秒数越低越好。",
scoreGuide: [
{ score: "5", range: "≤ 3.35 秒", note: "启动和加速明显占优" },
{ score: "4", range: "3.36 - 3.60 秒", note: "速度较好,能较快抢位" },
{ score: "3", range: "3.61 - 3.95 秒", note: "基础达标,比赛可用" },
{ score: "2", range: "3.96 - 4.25 秒", note: "偏弱,第一步需要加强" },
{ score: "1", range: "> 4.25 秒", note: "启动明显慢,建议重点补强" },
],
},
{
key: "explosivePowerScore",
label: "爆发力",
method: "可参考立定跳远、纵跳或第一步启动爆发感受。",
hint: "重点看蹬地发力和短时间输出能力。",
benchmarkLabel: "立定跳远参考",
benchmarkNote: "建议穿平底鞋测试两次取最好成绩,距离越长越好。",
scoreGuide: [
{ score: "5", range: "≥ 220 cm", note: "爆发输出强,启动质量高" },
{ score: "4", range: "200 - 219 cm", note: "爆发力较好" },
{ score: "3", range: "180 - 199 cm", note: "基础达标" },
{ score: "2", range: "160 - 179 cm", note: "偏弱,需要加强下肢爆发" },
{ score: "1", range: "< 160 cm", note: "爆发力明显不足" },
],
},
{
key: "agilityScore",
label: "敏捷性",
method: "做折返跑、T-test 或小碎步变向练习后评分。",
hint: "重点看变向反应和急停再启动能力。",
benchmarkLabel: "T-Test 参考",
benchmarkNote: "越快越好,测试时注意侧滑步和转身控制。",
scoreGuide: [
{ score: "5", range: "≤ 10.2 秒", note: "变向和回位非常积极" },
{ score: "4", range: "10.21 - 11.0 秒", note: "敏捷性较好" },
{ score: "3", range: "11.01 - 12.0 秒", note: "基础达标" },
{ score: "2", range: "12.01 - 13.0 秒", note: "变向偏慢" },
{ score: "1", range: "> 13.0 秒", note: "急停与再启动能力较弱" },
],
},
{
key: "enduranceScore",
label: "耐力",
method: "优先做 12 分钟跑:热身后连续跑 12 分钟,记录总距离。没有跑道或计距条件时,再用 20 分钟连续多球或折返跑后半段状态做替代判断。",
hint: "填写时尽量写清测试类型和结果,例如“12 分钟跑 2350 米”或“20 分钟多球后最后 5 分钟明显掉速”。不要只按主观感觉随意打分。",
benchmarkLabel: "优先参考12 分钟跑",
benchmarkNote: "分数优先按 12 分钟跑总距离判断。只有无法测距离时,才参考连续多球/折返跑在中后段是否明显掉速,手动选择最接近的档位。",
scoreGuide: [
{ score: "5", range: "≥ 2900 米", note: "末段仍能稳住配速,长时间训练后还有余量" },
{ score: "4", range: "2600 - 2899 米", note: "整体耐力较好,后程有轻微下降但节奏稳定" },
{ score: "3", range: "2200 - 2599 米", note: "基础达标,30-45 分钟训练后还能维持常规节奏" },
{ score: "2", range: "1800 - 2199 米", note: "中后段掉速明显,连续多球或折返跑容易顶不住" },
{ score: "1", range: "< 1800 米", note: "耐力偏弱,短时间内就出现明显喘和失速,需优先提升" },
],
},
{
key: "flexibilityScore",
label: "柔韧性",
method: "可参考坐位体前屈、腿后侧拉伸或全身活动范围。",
hint: "重点看下肢与躯干活动度是否限制击球动作。",
benchmarkLabel: "坐位体前屈参考",
benchmarkNote: "单位为厘米,数值越高越好。",
scoreGuide: [
{ score: "5", range: "≥ 18 cm", note: "柔韧性优秀,动作舒展" },
{ score: "4", range: "12 - 17 cm", note: "柔韧性较好" },
{ score: "3", range: "6 - 11 cm", note: "基础达标" },
{ score: "2", range: "0 - 5 cm", note: "活动范围偏紧" },
{ score: "1", range: "< 0 cm", note: "柔韧性明显不足" },
],
},
{
key: "coreStabilityScore",
label: "核心稳定性",
method: "可参考平板支撑、单腿稳定或挥拍时躯干稳定感。",
hint: "核心越稳,击球连贯性和受力传导通常越好。",
benchmarkLabel: "平板支撑参考",
benchmarkNote: "动作保持标准前提下计时,时间越长越好。",
scoreGuide: [
{ score: "5", range: "≥ 180 秒", note: "核心稳定性非常好" },
{ score: "4", range: "120 - 179 秒", note: "核心较稳" },
{ score: "3", range: "75 - 119 秒", note: "基础达标" },
{ score: "2", range: "45 - 74 秒", note: "稳定性偏弱" },
{ score: "1", range: "< 45 秒", note: "核心支撑不足" },
],
},
{
key: "shoulderMobilityScore",
label: "肩部灵活性",
method: "持拍绕肩、肩关节活动测试或发球动作舒展程度。",
hint: "发球和高压动作受肩部活动度影响很大。",
benchmarkLabel: "背后摸手测试参考",
benchmarkNote: "记录双手中指间距,越接近或重叠越好;如伴随疼痛需下调 1 档。",
scoreGuide: [
{ score: "5", range: "重叠 ≥ 5 cm", note: "肩部活动范围非常好" },
{ score: "4", range: "接触或间距 ≤ 3 cm", note: "活动度较好" },
{ score: "3", range: "间距 4 - 10 cm", note: "基础达标" },
{ score: "2", range: "间距 11 - 20 cm", note: "发球舒展度可能受限" },
{ score: "1", range: "间距 > 20 cm", note: "肩部灵活性明显不足" },
],
},
{
key: "hipMobilityScore",
label: "髋部灵活性",
method: "看开髋、弓步、转髋和侧向移动的流畅度。",
hint: "髋部活动不足会直接影响移动、转体和蹬转发力。",
benchmarkLabel: "主动直腿抬高参考",
benchmarkNote: "仰卧抬腿,记录大腿与地面夹角;越高越好,可用手机量角辅助。",
scoreGuide: [
{ score: "5", range: "≥ 85°", note: "髋部与后侧链活动优秀" },
{ score: "4", range: "75° - 84°", note: "活动度较好" },
{ score: "3", range: "65° - 74°", note: "基础达标" },
{ score: "2", range: "55° - 64°", note: "开髋和转髋受限" },
{ score: "1", range: "< 55°", note: "髋部活动明显不足" },
],
},
] as const satisfies readonly AssessmentFieldConfig[];
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;
};
type TutorialVisualRecord = {
id: number;
title: string;
category?: string | null;
description?: string | null;
thumbnailUrl?: string | null;
externalUrl?: string | null;
};
type TrainingProfileDraft = {
skillLevel: "beginner" | "intermediate" | "advanced";
manualNtrpRating: string;
heightCm: string;
weightKg: string;
sprintSpeedScore: string;
explosivePowerScore: string;
agilityScore: string;
enduranceScore: string;
flexibilityScore: string;
coreStabilityScore: string;
shoulderMobilityScore: string;
hipMobilityScore: string;
assessmentNotes: string;
};
function buildDraftFromUser(user: Record<string, any> | null | undefined): TrainingProfileDraft {
return {
skillLevel: (user?.skillLevel as TrainingProfileDraft["skillLevel"]) || "beginner",
manualNtrpRating: user?.manualNtrpRating != null ? String(user.manualNtrpRating) : "",
heightCm: user?.heightCm != null ? String(user.heightCm) : "",
weightKg: user?.weightKg != null ? String(user.weightKg) : "",
sprintSpeedScore: user?.sprintSpeedScore != null ? String(user.sprintSpeedScore) : "",
explosivePowerScore: user?.explosivePowerScore != null ? String(user.explosivePowerScore) : "",
agilityScore: user?.agilityScore != null ? String(user.agilityScore) : "",
enduranceScore: user?.enduranceScore != null ? String(user.enduranceScore) : "",
flexibilityScore: user?.flexibilityScore != null ? String(user.flexibilityScore) : "",
coreStabilityScore: user?.coreStabilityScore != null ? String(user.coreStabilityScore) : "",
shoulderMobilityScore: user?.shoulderMobilityScore != null ? String(user.shoulderMobilityScore) : "",
hipMobilityScore: user?.hipMobilityScore != null ? String(user.hipMobilityScore) : "",
assessmentNotes: user?.assessmentNotes ?? "",
};
}
function parseOptionalNumber(value: string) {
if (!value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalInteger(value: string) {
if (!value.trim()) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function formatDateTime(value?: string | Date | null) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return formatDateTimeShanghai(date);
}
function formatScoreLabel(value: unknown) {
return typeof value === "number" ? `${value}/5` : "未填写";
}
function scrollToSection(sectionId: string) {
if (typeof document === "undefined") return;
document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function normalizeExerciseText(...values: Array<string | null | undefined>) {
return values.join(" ").replace(/\s+/g, " ").trim().toLowerCase();
}
function inferExerciseTutorialCategories(exercise: Exercise) {
const categoryHints: string[] = [];
const text = normalizeExerciseText(exercise.name, exercise.description, exercise.tips, exercise.category);
if (exercise.category === "影子挥拍") categoryHints.push("shadow");
if (exercise.category === "脚步移动") categoryHints.push("footwork");
if (exercise.category === "墙壁练习") categoryHints.push("wall");
if (exercise.category === "体能训练") categoryHints.push("fitness");
if (/正手/.test(text)) categoryHints.push("forehand");
if (/反手/.test(text)) categoryHints.push("backhand");
if (/发球|抛球/.test(text)) categoryHints.push("serve");
if (/截击|网前/.test(text)) categoryHints.push("volley");
if (/影子|挥拍|空练/.test(text)) categoryHints.push("shadow");
if (/脚步|步伐|移动|回位|交叉步|碎步|标志盘|z字|侧滑|折返|冲刺/.test(text)) categoryHints.push("footwork");
if (/墙|对墙/.test(text)) categoryHints.push("wall");
if (/热身|拉伸|放松|弹力带|肩袖|核心|平板|猫牛|婴儿式|体能|爆发|耐力|敏捷|灵活/.test(text)) categoryHints.push("fitness");
if (/战术|比赛|策略/.test(text)) categoryHints.push("strategy");
return Array.from(new Set(categoryHints));
}
function scoreTutorialRelevance(exercise: Exercise, tutorial: TutorialVisualRecord) {
const exerciseText = normalizeExerciseText(exercise.name, exercise.description, exercise.tips, exercise.category);
const tutorialText = normalizeExerciseText(tutorial.title, tutorial.description, tutorial.category);
let score = 0;
const sharedTokens = [
"正手",
"反手",
"发球",
"截击",
"脚步",
"影子",
"挥拍",
"墙",
"体能",
"战术",
"比赛",
"上旋",
"基础",
"热身",
"拉伸",
"核心",
"肩袖",
"敏捷",
];
for (const token of sharedTokens) {
if (exerciseText.includes(token.toLowerCase()) && tutorialText.includes(token.toLowerCase())) {
score += 2;
}
}
if ((/空练|影子|挥拍/.test(exerciseText)) && tutorial.category === "shadow") score += 5;
if ((/热身|拉伸|放松|肩袖|核心|敏捷|弹力带/.test(exerciseText)) && tutorial.category === "fitness") score += 5;
if ((/标志盘|z字|脚步|侧滑|交叉步|回位|折返/.test(exerciseText)) && tutorial.category === "footwork") score += 5;
if ((/正手/.test(exerciseText)) && tutorial.category === "forehand") score += 4;
if ((/反手/.test(exerciseText)) && tutorial.category === "backhand") score += 4;
if ((/发球/.test(exerciseText)) && tutorial.category === "serve") score += 4;
if ((/墙/.test(exerciseText)) && tutorial.category === "wall") score += 4;
return score;
}
function findExerciseVisual(
exercise: Exercise,
tutorialsByCategory: Map<string, TutorialVisualRecord[]>,
) {
const categories = inferExerciseTutorialCategories(exercise);
for (const category of categories) {
const candidates = tutorialsByCategory.get(category) ?? [];
if (candidates.length === 0) continue;
const bestMatch = [...candidates].sort(
(left, right) => scoreTutorialRelevance(exercise, right) - scoreTutorialRelevance(exercise, left),
)[0];
if (bestMatch?.thumbnailUrl) {
return {
imageUrl: bestMatch.thumbnailUrl,
linkUrl: bestMatch.externalUrl || bestMatch.thumbnailUrl,
title: bestMatch.title,
};
}
}
return null;
}
export default function Training() {
const { user } = useAuth();
const [durationDays, setDurationDays] = useState(7);
const [selectedDay, setSelectedDay] = useState(1);
const [generatorPanelOpen, setGeneratorPanelOpen] = useState(() => {
if (typeof window === "undefined") return false;
return !window.matchMedia("(min-width: 1280px)").matches;
});
const [generateTaskId, setGenerateTaskId] = useState<string | null>(null);
const [adjustTaskId, setAdjustTaskId] = useState<string | null>(null);
const [profileDraft, setProfileDraft] = useState<TrainingProfileDraft>(() => buildDraftFromUser(user));
const utils = trpc.useUtils();
const { data: activePlan, isLoading: planLoading } = trpc.plan.active.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const { data: tutorialCatalog } = trpc.tutorial.list.useQuery({ topicArea: "tennis_skill" });
const generateTaskQuery = useBackgroundTask(generateTaskId);
const adjustTaskQuery = useBackgroundTask(adjustTaskId);
useEffect(() => {
setProfileDraft(buildDraftFromUser(user));
}, [user]);
useEffect(() => {
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(min-width: 1280px)");
const syncPanelState = () => {
if (!mediaQuery.matches) {
setGeneratorPanelOpen(true);
}
};
syncPanelState();
mediaQuery.addEventListener?.("change", syncPanelState);
return () => mediaQuery.removeEventListener?.("change", syncPanelState);
}, []);
const profileMutation = trpc.profile.update.useMutation({
onSuccess: async () => {
await Promise.all([
utils.auth.me.invalidate(),
utils.profile.stats.invalidate(),
]);
toast.success("训练档案已保存");
},
onError: (error) => toast.error(`保存失败: ${error.message}`),
});
const generateMutation = trpc.plan.generate.useMutation({
onSuccess: (data) => {
setGenerateTaskId(data.taskId);
toast.success("训练计划任务已提交");
},
onError: (err) => toast.error("生成失败: " + err.message),
});
const adjustMutation = trpc.plan.adjust.useMutation({
onSuccess: (data) => {
setAdjustTaskId(data.taskId);
toast.success("训练计划调整任务已提交");
},
onError: (err) => toast.error("调整失败: " + err.message),
});
const recordMutation = trpc.record.create.useMutation({
onSuccess: () => toast.success("训练记录已创建"),
});
const savedDraft = useMemo(() => buildDraftFromUser(user), [user]);
const hasSystemNtrp = stats?.latestNtrpSnapshot?.rating != null;
const currentDisplayedNtrp = hasSystemNtrp
? stats?.latestNtrpSnapshot?.rating
: user?.manualNtrpRating ?? null;
const currentNtrpLabel = hasSystemNtrp
? `系统评分 ${Number(currentDisplayedNtrp || 0).toFixed(1)}`
: currentDisplayedNtrp != null
? `人工基线 ${Number(currentDisplayedNtrp).toFixed(1)}`
: "待填写人工基线";
const missingProfileFields = useMemo(() => {
const missing: string[] = [];
if (!profileDraft.heightCm.trim()) missing.push("身高");
if (!profileDraft.weightKg.trim()) missing.push("体重");
for (const field of ASSESSMENT_FIELDS) {
if (!profileDraft[field.key].trim()) {
missing.push(field.label);
}
}
if (!hasSystemNtrp && !profileDraft.manualNtrpRating.trim()) {
missing.push("人工 NTRP 基线");
}
return missing;
}, [hasSystemNtrp, profileDraft]);
const hasUnsavedChanges = JSON.stringify(profileDraft) !== JSON.stringify(savedDraft);
const canGenerate = missingProfileFields.length === 0 && !hasUnsavedChanges && !generateMutation.isPending;
const exercises = useMemo(() => {
if (!activePlan?.exercises) return [];
return (activePlan.exercises as Exercise[]).filter((exercise) => exercise.day === selectedDay);
}, [activePlan, selectedDay]);
const tutorialsByCategory = useMemo(() => {
const grouped = new Map<string, TutorialVisualRecord[]>();
for (const tutorial of (tutorialCatalog ?? []) as TutorialVisualRecord[]) {
if (!tutorial.category) continue;
const existing = grouped.get(tutorial.category) ?? [];
existing.push(tutorial);
grouped.set(tutorial.category, existing);
}
return grouped;
}, [tutorialCatalog]);
const exerciseVisualMap = useMemo(() => {
const visualMap = new Map<string, ReturnType<typeof findExerciseVisual>>();
for (const exercise of exercises) {
visualMap.set(`${exercise.day}:${exercise.name}:${exercise.category}`, findExerciseVisual(exercise, tutorialsByCategory));
}
return visualMap;
}, [exercises, tutorialsByCategory]);
const totalDays = activePlan?.durationDays || durationDays;
const generating = generateMutation.isPending || generateTaskQuery.data?.status === "queued" || generateTaskQuery.data?.status === "running";
const adjusting = adjustMutation.isPending || adjustTaskQuery.data?.status === "queued" || adjustTaskQuery.data?.status === "running";
useEffect(() => {
if (activePlan?.durationDays && selectedDay > activePlan.durationDays) {
setSelectedDay(1);
}
}, [activePlan?.durationDays, selectedDay]);
useEffect(() => {
if (generateTaskQuery.data?.status === "succeeded") {
toast.success("训练计划已生成");
utils.plan.active.invalidate();
utils.plan.list.invalidate();
setGenerateTaskId(null);
} else if (generateTaskQuery.data?.status === "failed") {
toast.error(`训练计划生成失败: ${generateTaskQuery.data.error || "未知错误"}`);
setGenerateTaskId(null);
}
}, [generateTaskQuery.data, utils.plan.active, utils.plan.list]);
useEffect(() => {
if (adjustTaskQuery.data?.status === "succeeded") {
toast.success("训练计划已调整");
utils.plan.active.invalidate();
utils.plan.list.invalidate();
const adjustmentNotes = (adjustTaskQuery.data.result as { adjustmentNotes?: string } | null)?.adjustmentNotes;
if (adjustmentNotes) {
toast.info(`调整说明: ${adjustmentNotes}`);
}
setAdjustTaskId(null);
} else if (adjustTaskQuery.data?.status === "failed") {
toast.error(`训练计划调整失败: ${adjustTaskQuery.data.error || "未知错误"}`);
setAdjustTaskId(null);
}
}, [adjustTaskQuery.data, utils.plan.active, utils.plan.list]);
const saveProfile = () => {
profileMutation.mutate({
skillLevel: profileDraft.skillLevel,
manualNtrpRating: hasSystemNtrp ? undefined : parseOptionalNumber(profileDraft.manualNtrpRating),
heightCm: parseOptionalNumber(profileDraft.heightCm),
weightKg: parseOptionalNumber(profileDraft.weightKg),
sprintSpeedScore: parseOptionalInteger(profileDraft.sprintSpeedScore),
explosivePowerScore: parseOptionalInteger(profileDraft.explosivePowerScore),
agilityScore: parseOptionalInteger(profileDraft.agilityScore),
enduranceScore: parseOptionalInteger(profileDraft.enduranceScore),
flexibilityScore: parseOptionalInteger(profileDraft.flexibilityScore),
coreStabilityScore: parseOptionalInteger(profileDraft.coreStabilityScore),
shoulderMobilityScore: parseOptionalInteger(profileDraft.shoulderMobilityScore),
hipMobilityScore: parseOptionalInteger(profileDraft.hipMobilityScore),
assessmentNotes: profileDraft.assessmentNotes.trim() ? profileDraft.assessmentNotes.trim() : null,
});
};
const triggerGenerate = () => {
generateMutation.mutate({
skillLevel: profileDraft.skillLevel,
durationDays,
});
};
const openGeneratorPanel = () => {
setGeneratorPanelOpen(true);
window.setTimeout(() => {
scrollToSection("training-generator-panel");
}, 50);
};
const renderGeneratorPanel = (options?: { desktop?: boolean }) => {
const desktop = options?.desktop ?? false;
return (
<Card
id="training-generator-panel"
className={cn(
"border-0 shadow-sm",
desktop && "w-[360px]",
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
{activePlan ? "重新生成计划" : "生成训练计划"}
</CardTitle>
<CardDescription className="mt-2">
NTRP
</CardDescription>
</div>
{desktop ? (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setGeneratorPanelOpen(false)}
aria-label="收起计划生成面板"
>
<ChevronRight className="h-4 w-4" />
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={String(durationDays)} onValueChange={(value) => setDurationDays(Number(value))}>
<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 className="rounded-2xl border border-border/60 bg-muted/20 p-4 text-sm">
<div className="flex items-center gap-2 font-medium">
<Trophy className="h-4 w-4 text-primary" />
</div>
<div className="mt-2 text-muted-foreground">
{hasSystemNtrp
? `系统自动判定 NTRP ${Number(stats?.latestNtrpSnapshot?.rating || 0).toFixed(1)}`
: profileDraft.manualNtrpRating
? `人工基线 NTRP ${profileDraft.manualNtrpRating}`
: "还没有人工 NTRP 基线,暂时不能生成计划"}
</div>
</div>
{missingProfileFields.length > 0 ? (
<Alert>
<Activity className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{missingProfileFields.join("、")}
</AlertDescription>
</Alert>
) : null}
{hasUnsavedChanges ? (
<Alert>
<ArrowRight className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
使
</AlertDescription>
</Alert>
) : null}
<Button
data-testid="training-generate-button"
onClick={triggerGenerate}
disabled={!canGenerate || generating}
className="w-full gap-2"
>
{generating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
{activePlan ? "重新生成训练计划" : "生成训练计划"}
</>
)}
</Button>
<div className="text-xs leading-6 text-muted-foreground">
NTRP
</div>
</CardContent>
</Card>
);
};
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="mt-1 text-sm text-muted-foreground"></p>
</div>
</div>
{generating || adjusting ? (
<Alert>
<ListTodo className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{generating ? "训练计划正在后台生成。" : "训练计划正在根据最近分析结果调整。"}
</AlertDescription>
</Alert>
) : null}
{activePlan ? (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge className="bg-emerald-500/10 text-emerald-700"></Badge>
<Badge variant="secondary" className="text-xs">
{activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"}
</Badge>
<Badge variant="outline" className="text-xs">{activePlan.durationDays}</Badge>
{activePlan.version > 1 ? (
<Badge variant="outline" className="text-xs">v{activePlan.version} </Badge>
) : null}
</div>
<div>
<CardTitle className="text-xl">{activePlan.title}</CardTitle>
<CardDescription className="mt-2">
</CardDescription>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => scrollToSection("training-profile-section")}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => adjustMutation.mutate({ planId: activePlan.id })}
disabled={adjusting}
className="gap-1"
>
{adjusting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
</div>
{activePlan.adjustmentNotes ? (
<div className="rounded-lg bg-primary/5 p-3 text-sm text-primary">
<strong></strong>{activePlan.adjustmentNotes}
</div>
) : null}
</CardHeader>
<CardContent>
<div className="mb-4 flex gap-2 overflow-x-auto pb-2">
{Array.from({ length: totalDays }, (_, index) => index + 1).map((day) => (
<button
key={day}
onClick={() => setSelectedDay(day)}
className={cn(
"h-10 w-10 shrink-0 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="mb-3 font-semibold"> {selectedDay} </h3>
{exercises.length > 0 ? (
<div className="space-y-3">
{exercises.map((exercise, index) => (
<div key={index} className="rounded-xl border p-4 transition-shadow hover:shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl",
categoryColors[exercise.category] || "bg-gray-50 text-gray-700",
)}>
{categoryIcons[exercise.category] || <Target className="h-4 w-4" />}
</div>
<div>
<h4 className="text-sm font-medium">{exercise.name}</h4>
<p className="mt-1 text-xs text-muted-foreground">{exercise.description}</p>
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{exercise.duration}
</span>
<span>{exercise.sets} × {exercise.reps}</span>
</div>
{exercise.tips ? (
<p className="mt-2 rounded-md bg-primary/5 px-2 py-1 text-xs text-primary">
💡 {exercise.tips}
</p>
) : null}
{(() => {
const visual = exerciseVisualMap.get(`${exercise.day}:${exercise.name}:${exercise.category}`);
if (!visual?.imageUrl) return null;
return (
<a
href={visual.linkUrl || visual.imageUrl}
target="_blank"
rel="noreferrer"
className="group mt-3 block max-w-sm overflow-hidden rounded-xl border border-border/60 bg-muted/10 transition-colors hover:border-primary/40"
>
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
<img
src={visual.imageUrl}
alt={`${exercise.name} 示意图`}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/25 to-transparent px-3 py-2 text-[11px] font-medium text-white">
</div>
</div>
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div>
<div className="text-xs font-medium text-foreground">{visual.title}</div>
<div className="mt-1 text-[11px] text-muted-foreground"></div>
</div>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
</a>
);
})()}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => {
recordMutation.mutate({
planId: activePlan.id,
exerciseName: exercise.name,
durationMinutes: exercise.duration,
});
}}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<Badge variant="outline" className="w-fit border-amber-300 bg-amber-50 text-amber-700">
</Badge>
<div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription className="mt-2">
3 7 14 30
</CardDescription>
</div>
</div>
<Button
size="sm"
onClick={openGeneratorPanel}
className="gap-2"
>
<Sparkles className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-base font-semibold">
{missingProfileFields.length === 0 ? "档案已可生成" : `仍缺 ${missingProfileFields.length}`}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{missingProfileFields.length === 0 ? "保存后即可直接生成训练计划。" : `待补项目:${missingProfileFields.join("、")}`}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-base font-semibold"> </div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
NTRP 线使
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-base font-semibold">{durationDays} </div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
</div>
</div>
</CardContent>
</Card>
)}
<section id="training-profile-section" className="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_auto]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<TestTube2 className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription>
1-5
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"> NTRP</div>
<div className="mt-2 text-lg font-semibold">{currentNtrpLabel}</div>
<div className="mt-2 text-xs text-muted-foreground">
{hasSystemNtrp ? "后续以系统自动判定为准" : "当前需先填写人工基线"}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{missingProfileFields.length === 0 ? "已完整" : `${missingProfileFields.length}`}</div>
<div className="mt-2 text-xs text-muted-foreground">
{missingProfileFields.length === 0 ? "可以直接生成训练计划" : `待补:${missingProfileFields.slice(0, 3).join("、")}${missingProfileFields.length > 3 ? "…" : ""}`}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">
{stats?.latestNtrpSnapshot?.rating != null ? Number(stats.latestNtrpSnapshot.rating).toFixed(1) : "暂无"}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{formatDateTime(stats?.latestNtrpSnapshot?.createdAt) || "还没有自动评分记录"}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select
value={profileDraft.skillLevel}
onValueChange={(value: TrainingProfileDraft["skillLevel"]) =>
setProfileDraft((current) => ({ ...current, skillLevel: value }))
}
>
<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"> NTRP 线</label>
<Select
value={profileDraft.manualNtrpRating || undefined}
onValueChange={(value) => setProfileDraft((current) => ({ ...current, manualNtrpRating: value }))}
disabled={hasSystemNtrp}
>
<SelectTrigger>
<SelectValue placeholder={hasSystemNtrp ? "系统评分已接管" : "请选择人工基线"} />
</SelectTrigger>
<SelectContent>
{NTRP_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{hasSystemNtrp
? "已有系统 NTRP 后,这里只保留为历史基线,不再作为当前值编辑。"
: "前期请人工填写一个当前水平基线,后续系统会自动接管。"}
</p>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<Ruler className="h-4 w-4 text-muted-foreground" />
cm
</label>
<Input
inputMode="decimal"
value={profileDraft.heightCm}
onChange={(event) => setProfileDraft((current) => ({ ...current, heightCm: event.target.value }))}
placeholder="例如 178"
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<Scale className="h-4 w-4 text-muted-foreground" />
kg
</label>
<Input
inputMode="decimal"
value={profileDraft.weightKg}
onChange={(event) => setProfileDraft((current) => ({ ...current, weightKg: event.target.value }))}
placeholder="例如 68"
/>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-border/60 bg-muted/20 px-4 py-4 text-sm text-muted-foreground">
1-5 1
</div>
{ASSESSMENT_FIELDS.map((field) => (
<div key={field.key} className="grid gap-3 rounded-2xl border border-border/60 bg-background/90 p-4 xl:grid-cols-[220px_minmax(0,1fr)_minmax(280px,340px)]">
<div className="space-y-2">
<div className="text-sm font-medium">{field.label}</div>
<Select
value={profileDraft[field.key] || undefined}
onValueChange={(value) =>
setProfileDraft((current) => ({ ...current, [field.key]: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="请选择 1-5 分" />
</SelectTrigger>
<SelectContent>
{SCORE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label} · {option.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl bg-muted/30 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-sm leading-6">{field.method}</div>
</div>
<div className="rounded-xl bg-muted/30 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-sm leading-6">{field.hint}</div>
</div>
</div>
<div className="rounded-xl border border-primary/10 bg-primary/5 px-4 py-3">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-sm font-medium">{field.benchmarkLabel}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{field.benchmarkNote}</div>
<div className="mt-3 space-y-2">
{field.scoreGuide.map((guide) => {
const isSelected = profileDraft[field.key] === guide.score;
return (
<button
key={guide.score}
type="button"
onClick={() =>
setProfileDraft((current) => ({ ...current, [field.key]: guide.score }))
}
className={cn(
"flex w-full items-start justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-colors",
isSelected
? "border-primary bg-background shadow-sm"
: "border-border/60 bg-background/80 hover:border-primary/40",
)}
>
<div>
<div className="text-sm font-medium">{guide.score} </div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{guide.note}</div>
</div>
<div className="shrink-0 text-sm font-semibold text-primary">{guide.range}</div>
</button>
);
})}
</div>
</div>
</div>
))}
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
rows={4}
value={profileDraft.assessmentNotes}
onChange={(event) => setProfileDraft((current) => ({ ...current, assessmentNotes: event.target.value }))}
placeholder="例如:右肩偏紧、最近恢复期、脚踝旧伤、目前每周可训练 3 次。"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={saveProfile} disabled={profileMutation.isPending}>
{profileMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
</Button>
{hasUnsavedChanges ? (
<Badge variant="outline"></Badge>
) : (
<Badge className="bg-emerald-500/10 text-emerald-700"></Badge>
)}
</div>
</CardContent>
</Card>
<div className="xl:sticky xl:top-24 xl:self-start">
<div className="xl:hidden">
{renderGeneratorPanel()}
</div>
<div className="hidden xl:block">
<Collapsible open={generatorPanelOpen} onOpenChange={setGeneratorPanelOpen}>
<div className="flex items-start justify-end gap-3">
{!generatorPanelOpen ? (
<CollapsibleTrigger asChild>
<Card className="w-[76px] border-0 shadow-sm">
<button
type="button"
className="flex h-[320px] w-full flex-col items-center justify-between rounded-[24px] px-3 py-5 text-center"
>
<div className="rounded-2xl bg-primary/10 p-3 text-primary">
<Sparkles className="h-5 w-5" />
</div>
<div className="[writing-mode:vertical-rl] rotate-180 text-sm font-semibold tracking-[0.18em] text-foreground">
{activePlan ? "重新生成计划" : "生成训练计划"}
</div>
<div className="flex flex-col items-center gap-2 text-[11px] text-muted-foreground">
<span>{durationDays}</span>
<span>{currentNtrpLabel}</span>
<ChevronLeft className="h-4 w-4 text-foreground" />
</div>
</button>
</Card>
</CollapsibleTrigger>
) : null}
<CollapsibleContent forceMount className={cn(!generatorPanelOpen && "hidden")}>
{renderGeneratorPanel({ desktop: true })}
</CollapsibleContent>
</div>
</Collapsible>
</div>
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Ruler className="h-5 w-5 text-sky-600" />
<div>
<div className="text-sm text-muted-foreground"> / </div>
<div className="mt-1 text-xl font-semibold">
{profileDraft.heightCm || "--"} / {profileDraft.weightKg || "--"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Flame className="h-5 w-5 text-amber-600" />
<div>
<div className="text-sm text-muted-foreground"> / </div>
<div className="mt-1 text-xl font-semibold">
{formatScoreLabel(parseOptionalInteger(profileDraft.sprintSpeedScore))} · {formatScoreLabel(parseOptionalInteger(profileDraft.explosivePowerScore))}
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Target className="h-5 w-5 text-emerald-600" />
<div>
<div className="text-sm text-muted-foreground"> / / </div>
<div className="mt-1 text-xl font-semibold">
{formatScoreLabel(parseOptionalInteger(profileDraft.agilityScore))}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}