import { useMemo, useState } from "react"; import { useAuth } from "@/_core/hooks/useAuth"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { BookOpen, CheckCircle2, ChevronRight, Clock3, ExternalLink, Flame, Star, Target, Trophy, type LucideIcon, } from "lucide-react"; type TutorialRecord = Record; const CATEGORY_META: Record = { forehand: { label: "正手", icon: Target, tone: "bg-green-500/10 text-green-700" }, backhand: { label: "反手", icon: Target, tone: "bg-blue-500/10 text-blue-700" }, serve: { label: "发球", icon: Target, tone: "bg-violet-500/10 text-violet-700" }, volley: { label: "截击", icon: Target, tone: "bg-orange-500/10 text-orange-700" }, footwork: { label: "脚步", icon: Flame, tone: "bg-yellow-500/10 text-yellow-700" }, shadow: { label: "影子挥拍", icon: BookOpen, tone: "bg-indigo-500/10 text-indigo-700" }, wall: { label: "墙壁练习", icon: Target, tone: "bg-pink-500/10 text-pink-700" }, fitness: { label: "体能", icon: Flame, tone: "bg-rose-500/10 text-rose-700" }, strategy: { label: "战术", icon: Trophy, tone: "bg-teal-500/10 text-teal-700" }, }; const SKILL_META: Record = { beginner: { label: "初级", tone: "bg-emerald-500/10 text-emerald-700" }, intermediate: { label: "中级", tone: "bg-amber-500/10 text-amber-700" }, advanced: { label: "高级", tone: "bg-rose-500/10 text-rose-700" }, }; function parseStringArray(value: unknown) { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === "string"); } if (typeof value === "string") { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; } catch { return []; } } return []; } function isTutorialCompleted(progress: TutorialRecord | undefined) { return progress?.completed === 1 || progress?.watched === 1; } function formatEffortMinutes(tutorial: TutorialRecord) { const effort = tutorial.estimatedEffortMinutes || (tutorial.duration ? Math.round(tutorial.duration / 60) : 0); return effort > 0 ? `${effort} 分钟` : "按需学习"; } export default function Tutorials() { const { user } = useAuth(); const utils = trpc.useUtils(); const [selectedCategory, setSelectedCategory] = useState("all"); const [selectedSkill, setSelectedSkill] = useState("all"); const [draftNotes, setDraftNotes] = useState>({}); const tutorialsQuery = trpc.tutorial.list.useQuery({ topicArea: "tennis_skill" }); const progressQuery = trpc.tutorial.progress.useQuery(undefined, { enabled: !!user }); const updateProgress = trpc.tutorial.updateProgress.useMutation({ onSuccess: async () => { await utils.tutorial.progress.invalidate(); toast.success("教程进度已更新"); }, }); const tutorials = tutorialsQuery.data ?? []; const progressMap = useMemo(() => { const map: Record = {}; (progressQuery.data ?? []).forEach((item: TutorialRecord) => { map[item.tutorialId] = item; }); return map; }, [progressQuery.data]); const filteredTutorials = useMemo( () => tutorials.filter((tutorial) => { if (selectedCategory !== "all" && tutorial.category !== selectedCategory) return false; if (selectedSkill !== "all" && tutorial.skillLevel !== selectedSkill) return false; return true; }), [selectedCategory, selectedSkill, tutorials], ); const categories = useMemo( () => Array.from(new Set(tutorials.map((tutorial) => tutorial.category).filter(Boolean))), [tutorials], ); const completedTutorials = useMemo( () => tutorials.filter((tutorial) => isTutorialCompleted(progressMap[tutorial.id])), [progressMap, tutorials], ); const handleSaveNotes = (tutorialId: number) => { const notes = draftNotes[tutorialId] ?? progressMap[tutorialId]?.notes ?? ""; updateProgress.mutate({ tutorialId, notes }); }; const handleComplete = (tutorialId: number) => { updateProgress.mutate({ tutorialId, completed: 1, watched: 1 }); }; const handleSelfScore = (tutorialId: number, score: number) => { updateProgress.mutate({ tutorialId, selfScore: score }); }; if (tutorialsQuery.isLoading) { return (
); } return (
网球教程库 仅保留网球训练相关内容

专注正手、反手、发球、脚步和比赛能力

这里现在只保留和网球训练直接相关的教程。你可以按动作类别和水平筛选,记录自评与训练笔记,把教程真正沉淀到自己的日常练习里。

教程总数 {tutorials.length} 已完成 {completedTutorials.length} 当前筛选 {selectedCategory === "all" ? "全部分类" : (CATEGORY_META[selectedCategory] || { label: selectedCategory }).label} {" · "} {selectedSkill === "all" ? "全部级别" : (SKILL_META[selectedSkill] || { label: selectedSkill }).label}

网球基础教程

选择一个动作主题,完成学习、自评和训练复盘。

已完成 {completedTutorials.length}/{tutorials.length}
{categories.map((category) => ( ))}
{Object.entries(SKILL_META).map(([key, meta]) => ( ))}
{filteredTutorials.map((tutorial) => { const progress = progressMap[tutorial.id]; const completed = isTutorialCompleted(progress); const category = CATEGORY_META[tutorial.category || "forehand"] || CATEGORY_META.forehand; const skill = SKILL_META[tutorial.skillLevel || "beginner"] || SKILL_META.beginner; const keyPoints = parseStringArray(tutorial.keyPoints); const commonMistakes = parseStringArray(tutorial.commonMistakes); return (
{tutorial.thumbnailUrl ? ( <> {`${tutorial.title}
) : null}
{category.label} {skill.label}
{completed ? : null}
{tutorial.title}
{tutorial.description}
{formatEffortMinutes(tutorial)} {keyPoints.length} 个要点
{progress?.selfScore ? (
{[1, 2, 3, 4, 5].map((score) => ( ))}
) : null}
{category.label} {skill.label}
{tutorial.title}
{tutorial.thumbnailUrl ? (
{`${tutorial.title}
) : null}

{tutorial.description}

{tutorial.externalUrl ? (
标准配图来源 Wikimedia Commons
) : null}

技术要点

{keyPoints.map((item) => (
{item}
))}

常见错误

{commonMistakes.map((item) => (
{item}
))}
{user ? (
自我评估与训练笔记
学完后给自己打分,并记录本次训练最需要修正的点。
{!completed ? ( ) : ( 已完成 )}
掌握程度
{[1, 2, 3, 4, 5].map((score) => ( ))}
学习笔记