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 = { "影子挥拍": , "脚步移动": , "体能训练": , "墙壁练习": , }; const categoryColors: Record = { "影子挥拍": "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 | 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) { 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, ) { 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(null); const [adjustTaskId, setAdjustTaskId] = useState(null); const [profileDraft, setProfileDraft] = useState(() => 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(); 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>(); 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 (
{activePlan ? "重新生成计划" : "生成训练计划"} 训练计划会综合技能水平、体测档案、人工或系统 NTRP、以及最近的分析结果。
{desktop ? ( ) : null}
当前用于生成的评分来源
{hasSystemNtrp ? `系统自动判定 NTRP ${Number(stats?.latestNtrpSnapshot?.rating || 0).toFixed(1)}` : profileDraft.manualNtrpRating ? `人工基线 NTRP ${profileDraft.manualNtrpRating}` : "还没有人工 NTRP 基线,暂时不能生成计划"}
{missingProfileFields.length > 0 ? ( 先补齐训练档案 当前还缺少:{missingProfileFields.join("、")} ) : null} {hasUnsavedChanges ? ( 先保存,再生成 你刚修改了训练档案。为了保证后台任务使用最新数据,请先保存训练档案。 ) : null}
当前计划生成前会先检查身高、体重、速度、爆发力、敏捷性、耐力、柔韧性、核心稳定性、肩部灵活性、髋部灵活性,以及人工或系统 NTRP。
); }; if (planLoading) { return (
); } return (

训练计划

先完善训练档案,再根据当前水平与体测信息生成更可靠的训练安排。

{generating || adjusting ? ( 后台任务执行中 {generating ? "训练计划正在后台生成。" : "训练计划正在根据最近分析结果调整。"} 你可以切换到其他页面,完成后会在任务中心显示结果。 ) : null} {activePlan ? (
已生成当前训练计划 {activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"} {activePlan.durationDays}天周期 {activePlan.version > 1 ? ( v{activePlan.version} 已调整 ) : null}
{activePlan.title} 该计划已作为当前主计划展示在页面顶部。你可以直接查看每日安排,或返回下方档案区更新评测后重新生成。
{activePlan.adjustmentNotes ? (
调整说明:{activePlan.adjustmentNotes}
) : null}
{Array.from({ length: totalDays }, (_, index) => index + 1).map((day) => ( ))}

第 {selectedDay} 天训练

{exercises.length > 0 ? (
{exercises.map((exercise, index) => (
{categoryIcons[exercise.category] || }

{exercise.name}

{exercise.description}

{exercise.duration}分钟 {exercise.sets}组 × {exercise.reps}次
{exercise.tips ? (

💡 {exercise.tips}

) : null} {(() => { const visual = exerciseVisualMap.get(`${exercise.day}:${exercise.name}:${exercise.category}`); if (!visual?.imageUrl) return null; return (
{`${exercise.name}
点击查看示意图
匹配教程:{visual.title}
可点击查看标准配图或对应参考页
); })()}
))}
) : (
该天暂无训练安排
)}
) : (
尚未生成当前训练计划
先完善训练档案,再生成专属训练安排 页面顶部会优先展示当前主训练计划。你还没有可用计划时,可以先补齐评测分数并保存,然后到右侧生成区提交 3 天、7 天、14 天或 30 天计划。
当前状态
{missingProfileFields.length === 0 ? "档案已可生成" : `仍缺 ${missingProfileFields.length} 项`}
{missingProfileFields.length === 0 ? "保存后即可直接生成训练计划。" : `待补项目:${missingProfileFields.join("、")}`}
建议流程
档案录入 → 保存 → 生成
建议先完成体测分数与 NTRP 基线,再提交后台任务,避免计划使用旧数据。
当前周期
{durationDays} 天
你可以在下方生成区切换周期长度,系统会基于当前档案重新排布内容。
)}
训练档案与评测前置信息 这些数据会作为训练计划生成前的固定输入。每个专项分值统一按 1-5 分记录,旁边给出评测方法和记录建议。
当前 NTRP
{currentNtrpLabel}
{hasSystemNtrp ? "后续以系统自动判定为准" : "当前需先填写人工基线"}
档案完整度
{missingProfileFields.length === 0 ? "已完整" : `缺 ${missingProfileFields.length} 项`}
{missingProfileFields.length === 0 ? "可以直接生成训练计划" : `待补:${missingProfileFields.slice(0, 3).join("、")}${missingProfileFields.length > 3 ? "…" : ""}`}
最近系统判定
{stats?.latestNtrpSnapshot?.rating != null ? Number(stats.latestNtrpSnapshot.rating).toFixed(1) : "暂无"}
{formatDateTime(stats?.latestNtrpSnapshot?.createdAt) || "还没有自动评分记录"}

{hasSystemNtrp ? "已有系统 NTRP 后,这里只保留为历史基线,不再作为当前值编辑。" : "前期请人工填写一个当前水平基线,后续系统会自动接管。"}

setProfileDraft((current) => ({ ...current, heightCm: event.target.value }))} placeholder="例如 178" />

记录当前实际身高即可,用于估算力量与负荷建议。

setProfileDraft((current) => ({ ...current, weightKg: event.target.value }))} placeholder="例如 68" />

记录当前稳定体重即可,用于调节训练量与体能负荷。

以下分数换算为成人业余网球训练的自测参考值。建议先做一次原始测试,再按参考区间录入 1-5 分;如果有年龄、伤病或恢复期因素,可结合体感上下调整 1 档。
{ASSESSMENT_FIELDS.map((field) => (
{field.label}
评测方法
{field.method}
记录方式
{field.hint}
分数测试数据
{field.benchmarkLabel}
{field.benchmarkNote}
{field.scoreGuide.map((guide) => { const isSelected = profileDraft[field.key] === guide.score; return ( ); })}
))}