From 143c60a054af8ddf40cedf4d89acdfefad3222a9 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sun, 15 Mar 2026 12:01:21 +0800 Subject: [PATCH] Add optimized tutorial cover images --- Dockerfile | 3 + client/src/pages/Tutorials.tsx | 617 ++++++++++++++++++++------------- docs/verified-features.md | 3 +- server/_core/index.ts | 2 + server/tutorialImageCatalog.ts | 69 ++++ server/tutorialImages.test.ts | 43 +++ server/tutorialImages.ts | 178 ++++++++++ 7 files changed, 673 insertions(+), 242 deletions(-) create mode 100644 server/tutorialImageCatalog.ts create mode 100644 server/tutorialImages.test.ts create mode 100644 server/tutorialImages.ts diff --git a/Dockerfile b/Dockerfile index 3637550..3b8e25a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ FROM node:22-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production RUN corepack enable +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY package.json pnpm-lock.yaml ./ COPY patches ./patches RUN pnpm install --prod --frozen-lockfile diff --git a/client/src/pages/Tutorials.tsx b/client/src/pages/Tutorials.tsx index 4c4c3d4..7c23519 100644 --- a/client/src/pages/Tutorials.tsx +++ b/client/src/pages/Tutorials.tsx @@ -1,320 +1,455 @@ +import { useMemo, useState } from "react"; import { useAuth } from "@/_core/hooks/useAuth"; -import { trpc } from "@/lib/trpc"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { Textarea } from "@/components/ui/textarea"; -import { Progress } from "@/components/ui/progress"; 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 { useState, useMemo } from "react"; import { - BookOpen, Play, CheckCircle2, Star, Target, - ChevronRight, Filter, AlertTriangle, Lightbulb, - ArrowUpDown, Clock, Dumbbell + BookOpen, + CheckCircle2, + ChevronRight, + Clock3, + ExternalLink, + Flame, + Star, + Target, + Trophy, + type LucideIcon, } from "lucide-react"; -const CATEGORY_LABELS: Record = { - forehand: { label: "正手", icon: , color: "bg-green-100 text-green-700" }, - backhand: { label: "反手", icon: , color: "bg-blue-100 text-blue-700" }, - serve: { label: "发球", icon: , color: "bg-purple-100 text-purple-700" }, - volley: { label: "截击", icon: , color: "bg-orange-100 text-orange-700" }, - footwork: { label: "脚步", icon: , color: "bg-yellow-100 text-yellow-700" }, - shadow: { label: "影子挥拍", icon: , color: "bg-indigo-100 text-indigo-700" }, - wall: { label: "墙壁练习", icon: , color: "bg-pink-100 text-pink-700" }, - fitness: { label: "体能", icon: , color: "bg-red-100 text-red-700" }, - strategy: { label: "战术", icon: , color: "bg-teal-100 text-teal-700" }, +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_LABELS: Record = { - beginner: { label: "初级", color: "bg-emerald-100 text-emerald-700" }, - intermediate: { label: "中级", color: "bg-amber-100 text-amber-700" }, - advanced: { label: "高级", color: "bg-rose-100 text-rose-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 [selectedCategory, setSelectedCategory] = useState("all"); - const [selectedSkill, setSelectedSkill] = useState("all"); - const [selectedTutorial, setSelectedTutorial] = useState(null); - const [notes, setNotes] = useState(""); + const utils = trpc.useUtils(); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedSkill, setSelectedSkill] = useState("all"); + const [draftNotes, setDraftNotes] = useState>({}); - const { data: tutorials, isLoading } = trpc.tutorial.list.useQuery({ - category: selectedCategory === "all" ? undefined : selectedCategory, - skillLevel: selectedSkill === "all" ? undefined : selectedSkill, - }); - - const { data: progressData } = trpc.tutorial.progress.useQuery(undefined, { enabled: !!user }); + 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: () => toast.success("进度已更新"), + onSuccess: async () => { + await utils.tutorial.progress.invalidate(); + toast.success("教程进度已更新"); + }, }); + const tutorials = tutorialsQuery.data ?? []; const progressMap = useMemo(() => { - const map: Record = {}; - progressData?.forEach((p: any) => { map[p.tutorialId] = p; }); + const map: Record = {}; + (progressQuery.data ?? []).forEach((item: TutorialRecord) => { + map[item.tutorialId] = item; + }); return map; - }, [progressData]); + }, [progressQuery.data]); - const totalTutorials = tutorials?.length || 0; - const watchedCount = tutorials?.filter((t: any) => progressMap[t.id]?.watched).length || 0; - const progressPercent = totalTutorials > 0 ? Math.round((watchedCount / totalTutorials) * 100) : 0; + 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(() => { - const cats = new Set(); - tutorials?.forEach((t: any) => cats.add(t.category)); - return Array.from(cats); - }, [tutorials]); + const categories = useMemo( + () => Array.from(new Set(tutorials.map((tutorial) => tutorial.category).filter(Boolean))), + [tutorials], + ); - const handleMarkWatched = (tutorialId: number) => { - updateProgress.mutate({ tutorialId, watched: 1 }); - }; + 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 }); - setNotes(""); - toast.success("笔记已保存"); + }; + + const handleComplete = (tutorialId: number) => { + updateProgress.mutate({ tutorialId, completed: 1, watched: 1 }); }; const handleSelfScore = (tutorialId: number, score: number) => { updateProgress.mutate({ tutorialId, selfScore: score }); }; - if (isLoading) { + if (tutorialsQuery.isLoading) { return ( -
-
+
+
); } return (
- {/* Header */} -
-

- - 教程库 -

-

查看动作分解、要点说明和常见错误

-
- - {/* Progress Overview */} - - -
- 学习进度 - {watchedCount}/{totalTutorials} 已学习 +
+
+
+
+ + + 网球教程库 + + 仅保留网球训练相关内容 +
+

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

+

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

- -

{progressPercent}% 完成

- - - {/* Filters */} -
-
- - 分类: +
+ + + 教程总数 + {tutorials.length} + + + + + 已完成 + {completedTutorials.length} + + + + + 当前筛选 + + {selectedCategory === "all" ? "全部分类" : (CATEGORY_META[selectedCategory] || { label: selectedCategory }).label} + {" · "} + {selectedSkill === "all" ? "全部级别" : (SKILL_META[selectedSkill] || { label: selectedSkill }).label} + + + +
-
- - {Object.entries(CATEGORY_LABELS).map(([key, { label, icon }]) => ( +
+ +
+
+
+

网球基础教程

+

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

+
+
+ 已完成 {completedTutorials.length}/{tutorials.length} +
+
+ +
+
- ))} -
-
+ {categories.map((category) => ( + + ))} +
-
-
- - 级别: -
-
- - {Object.entries(SKILL_LABELS).map(([key, { label }]) => ( +
- ))} + {Object.entries(SKILL_META).map(([key, meta]) => ( + + ))} +
-
- {/* Tutorial Grid */} -
- {tutorials?.map((tutorial: any) => { - const cat = CATEGORY_LABELS[tutorial.category] || { label: tutorial.category, color: "bg-gray-100 text-gray-700" }; - const skill = SKILL_LABELS[tutorial.skillLevel] || { label: tutorial.skillLevel, color: "bg-gray-100 text-gray-700" }; - const progress = progressMap[tutorial.id]; - const isWatched = progress?.watched === 1; - const keyPoints = typeof tutorial.keyPoints === "string" ? JSON.parse(tutorial.keyPoints) : tutorial.keyPoints || []; - const mistakes = typeof tutorial.commonMistakes === "string" ? JSON.parse(tutorial.commonMistakes) : tutorial.commonMistakes || []; +
+ {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 ( - - - - -
-
- {cat.label} - {skill.label} + return ( + + +
+ {tutorial.thumbnailUrl ? ( + <> + {`${tutorial.title} +
+ + ) : null} + +
+
+ {category.label} + {skill.label}
- {isWatched && } + {completed ? : null}
- {tutorial.title} - {tutorial.description} - - + +
+
{tutorial.title}
+
+ {tutorial.description} +
+
+
+ +
- - - {Math.round((tutorial.duration || 0) / 60)}分钟 - - - {keyPoints.length}个要点 - + + + {formatEffortMinutes(tutorial)} + {keyPoints.length} 个要点
- {progress?.selfScore && ( -
- {[1, 2, 3, 4, 5].map(s => ( - + + {progress?.selfScore ? ( +
+ {[1, 2, 3, 4, 5].map((score) => ( + ))} - 自评
- )} + ) : null} + + + + - - - -
- {cat.label} - {skill.label} - - - {Math.round((tutorial.duration || 0) / 60)}分钟 - -
- {tutorial.title} -
- - -
-

{tutorial.description}

- - {/* Key Points */} -
-

- - 技术要点 -

-
- {keyPoints.map((point: string, i: number) => ( -
- - {point} -
- ))} -
+ + +
+ {category.label} + {skill.label}
+ {tutorial.title} +
- {/* Common Mistakes */} -
-

- - 常见错误 -

-
- {mistakes.map((mistake: string, i: number) => ( -
- - {mistake} -
- ))} -
-
+ +
+ {tutorial.thumbnailUrl ? ( +
+ {`${tutorial.title} +
+ ) : null} + +

{tutorial.description}

+ + {tutorial.externalUrl ? ( +
+ 标准配图来源 + + Wikimedia Commons + + +
+ ) : null} - {/* Self Assessment */} - {user && (
-

自我评估

-
- 掌握程度: - {[1, 2, 3, 4, 5].map(s => ( - +

技术要点

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