From 1ce94f6f5762cda2ff73a5419a764f8f22b05fbb Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sun, 15 Mar 2026 17:39:20 +0800 Subject: [PATCH] Collapse training generator into right rail --- client/src/lib/changelog.ts | 16 + client/src/pages/Training.tsx | 1268 ++++++++++++++++++++++++++++----- docs/CHANGELOG.md | 18 + 3 files changed, 1123 insertions(+), 179 deletions(-) diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts index 06dd79a..636eca8 100644 --- a/client/src/lib/changelog.ts +++ b/client/src/lib/changelog.ts @@ -8,6 +8,22 @@ export type ChangeLogEntry = { }; export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [ + { + version: "2026.03.15-training-generator-collapse", + releaseDate: "2026-03-15", + repoVersion: "pending-commit", + summary: "训练计划生成面板在桌面端默认折叠到右侧,按需展开查看和重新生成。", + features: [ + "训练页右侧生成器在桌面端默认折叠为窄栏", + "点击右侧折叠栏可展开“重新生成计划”完整面板", + "移动端继续直接展示完整生成器,避免隐藏关键操作", + "未生成计划时点击“前往生成训练计划”会自动展开并滚动到生成面板", + ], + tests: [ + "pnpm check", + "pnpm build", + ], + }, { version: "2026.03.15-progress-time-actions", releaseDate: "2026-03-15", diff --git a/client/src/pages/Training.tsx b/client/src/pages/Training.tsx index e847ec5..4bb3df6 100644 --- a/client/src/pages/Training.tsx +++ b/client/src/pages/Training.tsx @@ -1,19 +1,214 @@ import { useEffect, useMemo, useState } 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 { 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +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 { - Target, Loader2, CheckCircle2, Circle, Clock, Dumbbell, - RefreshCw, Footprints, Hand, ArrowRight, Sparkles, ListTodo + 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: "按持续跑动、连续多球、间歇训练后的状态打分。", + hint: "重点看 30-60 分钟训练后是否还能保持节奏。", + benchmarkLabel: "12 分钟跑参考", + benchmarkNote: "如无法跑场,可用多球或折返跑的持续能力做近似判断,距离越长越好。", + scoreGuide: [ + { score: "5", range: "≥ 2900 米", note: "长时间训练后仍有充足余量" }, + { score: "4", range: "2600 - 2899 米", note: "耐力较好" }, + { score: "3", range: "2200 - 2599 米", note: "基础达标" }, + { 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 = { "影子挥拍": , "脚步移动": , @@ -39,19 +234,221 @@ type Exercise = { 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 [skillLevel, setSkillLevel] = useState<"beginner" | "intermediate" | "advanced">("beginner"); 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); @@ -72,22 +469,69 @@ export default function Training() { onSuccess: () => toast.success("训练记录已创建"), }); - const completeMutation = trpc.record.complete.useMutation({ - onSuccess: () => { - toast.success("训练已完成!"); - utils.profile.stats.invalidate(); - }, - }); + 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(e => e.day === selectedDay); + return (activePlan.exercises as Exercise[]).filter((exercise) => exercise.day === selectedDay); }, [activePlan, selectedDay]); - const totalDays = activePlan?.durationDays || 7; + 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("训练计划已生成"); @@ -116,6 +560,151 @@ export default function Training() { } }, [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 (
@@ -130,7 +719,7 @@ export default function Training() {

训练计划

-

按水平和周期生成训练安排

+

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

@@ -145,80 +734,38 @@ export default function Training() { ) : null} - {!activePlan ? ( - /* Generate new plan */ + {activePlan ? ( - - - - 生成训练计划 - - - 根据水平和目标生成训练安排 - - - -
-
- - -
-
- - -
-
- -
-
- ) : ( - /* Active plan display */ - <> - - -
+ +
+
+
+ 已生成当前训练计划 + + {activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"} + + {activePlan.durationDays}天周期 + {activePlan.version > 1 ? ( + v{activePlan.version} 已调整 + ) : null} +
+
- {activePlan.title} - - - {activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"} - - {activePlan.durationDays}天计划 - {activePlan.version > 1 && ( - v{activePlan.version} 已调整 - )} + {activePlan.title} + + 该计划已作为当前主计划展示在页面顶部。你可以直接查看每日安排,或返回下方档案区更新评测后重新生成。
+
+ +
+
- {activePlan.adjustmentNotes && ( -
- 调整说明:{activePlan.adjustmentNotes} -
- )} - - - {/* Day selector */} -
- {Array.from({ length: totalDays }, (_, i) => i + 1).map(day => ( - +
+ {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}
+
可点击查看标准配图或对应参考页
+
+ +
+
+ ); + })()} +
+
+ + +
+
))}
- -

第 {selectedDay} 天训练

- - {exercises.length > 0 ? ( -
- {exercises.map((ex, idx) => ( -
-
-
-
- {categoryIcons[ex.category] || } -
-
-

{ex.name}

-

{ex.description}

-
- - {ex.duration}分钟 - - {ex.sets}组 × {ex.reps}次 -
- {ex.tips && ( -

- 💡 {ex.tips} -

- )} -
-
- -
-
- ))} + ) : ( +
+ 该天暂无训练安排 +
+ )} + + + ) : ( + + +
+
+ + 尚未生成当前训练计划 + +
+ 先完善训练档案,再生成专属训练安排 + + 页面顶部会优先展示当前主训练计划。你还没有可用计划时,可以先补齐评测分数并保存,然后到右侧生成区提交 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 ( + + ); + })} +
+
+
+ ))} +
+ +
+ +