1244 行
54 KiB
TypeScript
1244 行
54 KiB
TypeScript
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>
|
||
);
|
||
}
|