From 4ac8c07215b715d6f082b919a21e3339f2a72e0a Mon Sep 17 00:00:00 2001 From: ditiqi Date: Wed, 8 Jan 2025 20:29:07 +0800 Subject: [PATCH 1/2] add --- apps/web/package.json | 1 - apps/web/src/app/main/course/page.tsx | 34 --- .../src/app/main/courses/instructor/page.tsx | 2 +- .../course/detail/CourseDetailContent.tsx | 84 ------- .../course/detail/CourseDetailContext.tsx | 6 +- .../CourseDetailDescription.tsx | 29 +++ .../CourseDetailNavBar.tsx | 47 ++++ .../Description/Overview.tsx | 67 ++++++ .../Description/index.ts | 0 ...eoPage.tsx => CourseDetailDisplayArea.tsx} | 33 +-- .../CourseDetailHeader/CourseDetailHeader.tsx | 57 +++++ .../course/detail/CourseDetailLayout.tsx | 52 +++-- .../models/course/detail/CourseSyllabus.tsx | 214 ------------------ .../detail/CourseSyllabus/CollapsedButton.tsx | 19 ++ .../detail/CourseSyllabus/CourseSyllabus.tsx | 105 +++++++++ .../detail/CourseSyllabus/LectureItem.tsx | 36 +++ .../detail/CourseSyllabus/SectionItem.tsx | 68 ++++++ .../detail/CourseSyllabus/SyllabusHeader.tsx | 18 ++ .../course/detail/CourseSyllabus/index.ts | 1 + .../models/course/manage/CourseBasicForm.tsx | 28 --- .../models/course/manage/CourseEditor.tsx | 17 +- .../course/manage/CourseEditorLayout.tsx | 87 +++---- .../manage/CourseForms/CourseBasicForm.tsx | 50 ++++ .../CourseForms/CourseTargetForm copy.tsx | 45 ++++ .../models/course/manage/navItems.tsx | 54 ++--- .../src/components/presentation/NavBar.tsx | 58 +++++ .../presentation/form/FormArrayField.tsx | 210 ++++++++++------- .../presentation/form/FormDynamicInputs.tsx | 154 +++++++++++++ .../presentation/form/FormInput.tsx | 167 ++++++++------ .../ControlButtons/Brightness.tsx | 32 +++ .../ControlButtons/FullScreen.tsx | 29 +++ .../video-player/ControlButtons/Play.tsx | 25 ++ .../video-player/ControlButtons/Setting.tsx | 62 +++++ .../video-player/ControlButtons/Speed.tsx | 59 +++++ .../video-player/ControlButtons/TimeLine.tsx | 64 ++++++ .../video-player/ControlButtons/Volume.tsx | 43 ++++ .../video-player/ControlButtons/index.ts | 4 + .../video-player/LoadingOverlay.tsx | 2 +- .../video-player/VideoControls.tsx | 201 +++------------- .../{VideoScreen.tsx => VideoDisplay.tsx} | 73 +++--- .../presentation/video-player/VideoPlayer.tsx | 22 +- .../video-player/VideoPlayerLayout.tsx | 7 +- .../presentation/video-player/interface.ts | 10 + .../presentation/video-player/utlis.ts | 2 +- apps/web/src/index.css | 34 --- apps/web/src/routes/index.tsx | 15 ++ pnpm-lock.yaml | 69 ------ 47 files changed, 1539 insertions(+), 957 deletions(-) delete mode 100644 apps/web/src/app/main/course/page.tsx delete mode 100644 apps/web/src/components/models/course/detail/CourseDetailContent.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts rename apps/web/src/components/models/course/detail/{CourseVideoPage.tsx => CourseDetailDisplayArea.tsx} (56%) create mode 100644 apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx delete mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/CollapsedButton.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/SyllabusHeader.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseSyllabus/index.ts delete mode 100644 apps/web/src/components/models/course/manage/CourseBasicForm.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx create mode 100644 apps/web/src/components/presentation/NavBar.tsx create mode 100644 apps/web/src/components/presentation/form/FormDynamicInputs.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx create mode 100644 apps/web/src/components/presentation/video-player/ControlButtons/index.ts rename apps/web/src/components/presentation/video-player/{VideoScreen.tsx => VideoDisplay.tsx} (87%) create mode 100644 apps/web/src/components/presentation/video-player/interface.ts diff --git a/apps/web/package.json b/apps/web/package.json index 9f4b82a..2e58707 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,7 +49,6 @@ "hls.js": "^1.5.18", "idb-keyval": "^6.2.1", "mitt": "^3.0.1", - "plyr-react": "^5.3.0", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", diff --git a/apps/web/src/app/main/course/page.tsx b/apps/web/src/app/main/course/page.tsx deleted file mode 100644 index 9f5dd6d..0000000 --- a/apps/web/src/app/main/course/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { CourseCard } from "@web/src/components/models/course/card/CourseCard"; -import { CourseDetail } from "@web/src/components/models/course/detail/CourseDetailContent"; -import { CourseSyllabus } from "@web/src/components/models/course/detail/CourseSyllabus"; - -export const CoursePage = () => { - // 假设这些数据从API获取 - const course: any = { - /* course data */ - }; - const sections: any = [ - /* sections data */ - ]; - - return ( -
-
- {/* 左侧课程详情 */} -
- -
- {/* 右侧课程大纲 */} -
- - { - console.log("Clicked lecture:", lectureId); - }} - /> -
-
-
- ); -}; diff --git a/apps/web/src/app/main/courses/instructor/page.tsx b/apps/web/src/app/main/courses/instructor/page.tsx index bd27993..55339af 100644 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ b/apps/web/src/app/main/courses/instructor/page.tsx @@ -52,7 +52,7 @@ export default function InstructorCoursesPage() { renderItem={(course) => ( { - navigate(`/course/${course.id}/detail`, { + navigate(`/course/${course.id}/manage`, { replace: true, }); }} diff --git a/apps/web/src/components/models/course/detail/CourseDetailContent.tsx b/apps/web/src/components/models/course/detail/CourseDetailContent.tsx deleted file mode 100644 index f533b3f..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailContent.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { CheckCircleIcon } from "@heroicons/react/24/outline"; -import { Course } from "@nice/common"; -import { motion } from "framer-motion"; -import React from "react"; -import CourseDetailSkeleton from "./CourseDetailSkeleton"; -interface CourseDetailProps { - course: Course; - isLoading: boolean; -} - -export const CourseDetailContent: React.FC = ({ - course, - isLoading, -}) => { - if (isLoading || !course) { - return ; - } - return ( -
- {/* 课程标题区域 */} -
-

{course.title}

- {course.subTitle && ( -

{course.subTitle}

- )} -
- - {/* 课程描述 */} -
-

{course.description}

-
- - {/* 学习目标 */} -
-

学习目标

-
- {course.objectives.map((objective, index) => ( -
- - {objective} -
- ))} -
-
- - {/* 适合人群 */} -
-

适合人群

-
- {course.audiences.map((audience, index) => ( -
- - {audience} -
- ))} -
-
- - {/* 课程要求 */} -
-

课程要求

-
    - {course.requirements.map((requirement, index) => ( -
  • {requirement}
  • - ))} -
-
- - {/* 可获得技能 */} -
-

可获得技能

-
- {course.skills.map((skill, index) => ( - - {skill} - - ))} -
-
-
- ); -}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index ffa25ff..72507b5 100644 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -9,6 +9,8 @@ interface CourseDetailContextType { selectedLectureId?: string | undefined; setSelectedLectureId?: React.Dispatch>; isLoading?: boolean; + isHeaderVisible: boolean; // 新增 + setIsHeaderVisible: (visible: boolean) => void; // 新增 } interface CourseFormProviderProps { children: ReactNode; @@ -34,7 +36,7 @@ export function CourseDetailProvider({ const [selectedLectureId, setSelectedLectureId] = useState< string | undefined >(undefined); - + const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增 return ( {children} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx new file mode 100644 index 0000000..a3c8114 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx @@ -0,0 +1,29 @@ +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +import { Course } from "@nice/common"; +import { motion } from "framer-motion"; +import React from "react"; +import CourseDetailSkeleton from "../CourseDetailSkeleton"; +import CourseDetailNavBar from "./CourseDetailNavBar"; +interface CourseDetailProps { + course: Course; + isLoading: boolean; +} + +export const CourseDetailDescription: React.FC = ({ + course, + isLoading, +}) => { + return ( + <> + + +
+ {isLoading || !course ? ( + + ) : ( + + )} +
+ + ); +}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx new file mode 100644 index 0000000..a5b862d --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx @@ -0,0 +1,47 @@ +import { NavBar } from "@web/src/components/presentation/NavBar"; +import { HomeIcon, BellIcon } from "@heroicons/react/24/outline"; +import { + DocumentTextIcon, + MagnifyingGlassIcon, + StarIcon, +} from "@heroicons/react/24/solid"; + +export default function CourseDetailNavBar() { + const navItems = [ + { + id: "search", + icon: , + label: "搜索", + }, + { + id: "overview", + icon: , + label: "概述", + }, + { + id: "notes", + icon: , + label: "备注", + }, + { + id: "announcements", + icon: , + label: "公告", + }, + { + id: "reviews", + icon: , + label: "评价", + }, + ]; + + return ( +
+ console.log("Selected:", id)} + /> +
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx new file mode 100644 index 0000000..334c36b --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx @@ -0,0 +1,67 @@ +import { useContext } from "react"; +import { CourseDetailContext } from "../../CourseDetailContext"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; + +export function Overview() { + const { course } = useContext(CourseDetailContext); + return ( + <> +
+ {/* 课程描述 */} +
+

{course?.description}

+
+ + {/* 学习目标 */} +
+

学习目标

+
+ {course?.objectives.map((objective, index) => ( +
+ + {objective} +
+ ))} +
+
+ + {/* 适合人群 */} +
+

适合人群

+
+ {course?.audiences.map((audience, index) => ( +
+ + {audience} +
+ ))} +
+
+ + {/* 课程要求 */} +
+

课程要求

+
    + {course?.requirements.map((requirement, index) => ( +
  • {requirement}
  • + ))} +
+
+ + {/* 可获得技能 */} +
+

可获得技能

+
+ {course?.skills.map((skill, index) => ( + + {skill} + + ))} +
+
+
+ + ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/components/models/course/detail/CourseVideoPage.tsx b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx similarity index 56% rename from apps/web/src/components/models/course/detail/CourseVideoPage.tsx rename to apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx index a083182..2db3ffa 100644 --- a/apps/web/src/components/models/course/detail/CourseVideoPage.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx @@ -1,23 +1,20 @@ -// components/CourseVideoPage.tsx +// components/CourseDetailDisplayArea.tsx import { motion, useScroll, useTransform } from "framer-motion"; import React from "react"; import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer"; -import { CourseDetailContent } from "./CourseDetailContent"; +import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription"; import { Course } from "@nice/common"; -interface CourseVideoPageProps { +interface CourseDetailDisplayAreaProps { course: Course; videoSrc?: string; videoPoster?: string; isLoading?: boolean; } -export const CourseVideoPage: React.FC = ({ - course, - videoSrc, - videoPoster, - isLoading = false, -}) => { +export const CourseDetailDisplayArea: React.FC< + CourseDetailDisplayAreaProps +> = ({ course, videoSrc, videoPoster, isLoading = false }) => { // 创建滚动动画效果 const { scrollY } = useScroll(); const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]); @@ -26,13 +23,16 @@ export const CourseVideoPage: React.FC = ({ return (
{/* 固定的视频区域 */} + {/* 移除 sticky 定位,让视频区域随页面滚动 */} -
+ className="w-full bg-black"> +
@@ -42,10 +42,13 @@ export const CourseVideoPage: React.FC = ({ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} - className="max-w-6xl mx-auto px-4 py-8"> - + className="w-full"> +
); }; -export default CourseVideoPage; +export default CourseDetailDisplayArea; diff --git a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx new file mode 100644 index 0000000..4ef5e54 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx @@ -0,0 +1,57 @@ +// components/Header.tsx +import { motion, useScroll, useTransform } from "framer-motion"; +import { useContext, useEffect, useState } from "react"; +import { CourseDetailContext } from "../CourseDetailContext"; + +export const CourseDetailHeader = () => { + const { scrollY } = useScroll(); + + const [lastScrollY, setLastScrollY] = useState(0); + const { course, isHeaderVisible, setIsHeaderVisible } = + useContext(CourseDetailContext); + useEffect(() => { + const updateHeader = () => { + const current = scrollY.get(); + const direction = current > lastScrollY ? "down" : "up"; + + if (direction === "down" && current > 100) { + setIsHeaderVisible(false); + } else if (direction === "up") { + setIsHeaderVisible(true); + } + + setLastScrollY(current); + }; + + // 使用 requestAnimationFrame 来优化性能 + const unsubscribe = scrollY.on("change", () => { + requestAnimationFrame(updateHeader); + }); + + return () => { + unsubscribe(); + }; + }, [lastScrollY, scrollY, setIsHeaderVisible]); + + return ( + +
+
+

{course?.title}

+
+ +
+
+ ); +}; + +export default CourseDetailHeader; diff --git a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx index 8e5b7ec..4f4ecd5 100644 --- a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx @@ -1,9 +1,10 @@ import { motion } from "framer-motion"; import { useContext, useState } from "react"; import { CourseDetailContext } from "./CourseDetailContext"; -import { CourseSyllabus } from "./CourseSyllabus"; -import { CourseDetailContent } from "./CourseDetailContent"; -import CourseVideoPage from "./CourseVideoPage"; + +import CourseDetailDisplayArea from "./CourseDetailDisplayArea"; +import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus"; +import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader"; export default function CourseDetailLayout() { const { course, selectedLectureId, isLoading, setSelectedLectureId } = @@ -15,28 +16,33 @@ export default function CourseDetailLayout() { const [isSyllabusOpen, setIsSyllabusOpen] = useState(false); return (
+ {/* 添加 Header 组件 */} {/* 主内容区域 */} - - + {" "} + {/* 添加这个包装 div */} + + + + {/* 课程大纲侧边栏 */} + setIsSyllabusOpen(!isSyllabusOpen)} /> - - - {/* 课程大纲侧边栏 */} - setIsSyllabusOpen(!isSyllabusOpen)} - /> +
); } diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus.tsx deleted file mode 100644 index 51f18cf..0000000 --- a/apps/web/src/components/models/course/detail/CourseSyllabus.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline"; -import { - ChevronDownIcon, - ClockIcon, - PlayCircleIcon, -} from "@heroicons/react/24/outline"; -import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; -import React, { useState, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { SectionDto } from "@nice/common"; - -interface CourseSyllabusProps { - sections: SectionDto[]; - onLectureClick?: (lectureId: string) => void; - isOpen: boolean; - onToggle: () => void; -} - -export const CourseSyllabus: React.FC = ({ - sections, - onLectureClick, - isOpen, - onToggle, -}) => { - const [expandedSections, setExpandedSections] = useState([]); - const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); - - const toggleSection = (sectionId: string) => { - setExpandedSections((prev) => - prev.includes(sectionId) - ? prev.filter((id) => id !== sectionId) - : [...prev, sectionId] - ); - - // 平滑滚动到选中的章节 - setTimeout(() => { - sectionRefs.current[sectionId]?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }, 100); - }; - - return ( - - {/* 收起时显示的展开按钮 */} - {!isOpen && ( - - - - )} - - {/* 展开的课程大纲 */} - - {isOpen && ( - - {/* 标题栏 */} -
-

课程大纲

- -
- - {/* 课程大纲内容 */} -
-
- {/* 原有的 sections mapping 内容 */} - {sections.map((section) => ( - - (sectionRefs.current[section.id] = - el) - } - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.3 }} - className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow"> - - - - {expandedSections.includes( - section.id - ) && ( - - {section.lectures.map( - (lecture) => ( - - onLectureClick?.( - lecture.id - ) - }> - -
-

- { - lecture.title - } -

- {lecture.description && ( -

- { - lecture.description - } -

- )} -
-
- - - { - lecture.duration - } - 分钟 - -
-
- ) - )} -
- )} -
-
- ))} -
-
-
- )} -
-
- ); -}; diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/CollapsedButton.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CollapsedButton.tsx new file mode 100644 index 0000000..08f04b3 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CollapsedButton.tsx @@ -0,0 +1,19 @@ +import { BookOpenIcon } from "@heroicons/react/24/outline"; +import { motion } from "framer-motion"; +import React from "react"; +interface CollapsedButtonProps { + onToggle: () => void; +} + +export const CollapsedButton: React.FC = ({ + onToggle, +}) => ( + + + +); diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx new file mode 100644 index 0000000..58212a1 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx @@ -0,0 +1,105 @@ +import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline"; +import { + ChevronDownIcon, + ClockIcon, + PlayCircleIcon, +} from "@heroicons/react/24/outline"; +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; +import React, { useState, useRef, useContext } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { SectionDto } from "@nice/common"; +import { SyllabusHeader } from "./SyllabusHeader"; +import { SectionItem } from "./SectionItem"; +import { CollapsedButton } from "./CollapsedButton"; +import { CourseDetailContext } from "../CourseDetailContext"; + +interface CourseSyllabusProps { + sections: SectionDto[]; + onLectureClick?: (lectureId: string) => void; + isOpen: boolean; + onToggle: () => void; +} + +export const CourseSyllabus: React.FC = ({ + sections, + onLectureClick, + isOpen, + onToggle, +}) => { + const { isHeaderVisible } = useContext(CourseDetailContext); + const [expandedSections, setExpandedSections] = useState([]); + const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + + const toggleSection = (sectionId: string) => { + setExpandedSections((prev) => + prev.includes(sectionId) + ? prev.filter((id) => id !== sectionId) + : [...prev, sectionId] + ); + + setTimeout(() => { + sectionRefs.current[sectionId]?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + }; + + return ( + <> + + {/* 收起时的悬浮按钮 */} + {!isOpen && ( + + + + )} + + + + {isOpen && ( + + + +
+
+ {sections.map((section) => ( + + (sectionRefs.current[ + section.id + ] = el) + } + section={section} + isExpanded={expandedSections.includes( + section.id + )} + onToggle={toggleSection} + onLectureClick={onLectureClick} + /> + ))} +
+
+
+ )} +
+
+ + ); +}; diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx new file mode 100644 index 0000000..9a87d75 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx @@ -0,0 +1,36 @@ +// components/CourseSyllabus/LectureItem.tsx + +import { Lecture } from "@nice/common"; +import React from "react"; +import { motion } from "framer-motion"; +import { ClockIcon, PlayCircleIcon } from "@heroicons/react/24/outline"; + +interface LectureItemProps { + lecture: Lecture; + onClick: (lectureId: string) => void; +} + +export const LectureItem: React.FC = ({ + lecture, + onClick, +}) => ( + onClick(lecture.id)}> + +
+

{lecture.title}

+ {lecture.description && ( +

+ {lecture.description} +

+ )} +
+
+ + {lecture.duration}分钟 +
+
+); diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx new file mode 100644 index 0000000..100fb6e --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx @@ -0,0 +1,68 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { SectionDto } from "@nice/common"; +import { AnimatePresence, motion } from "framer-motion"; +import React from "react"; +import { LectureItem } from "./LectureItem"; + +// components/CourseSyllabus/SectionItem.tsx +interface SectionItemProps { + section: SectionDto; + isExpanded: boolean; + onToggle: (sectionId: string) => void; + onLectureClick: (lectureId: string) => void; + ref: React.RefObject; +} + +export const SectionItem = React.forwardRef( + ({ section, isExpanded, onToggle, onLectureClick }, ref) => ( + + + + + {isExpanded && ( + + {section.lectures.map((lecture) => ( + + ))} + + )} + + + ) +); diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/SyllabusHeader.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/SyllabusHeader.tsx new file mode 100644 index 0000000..1b4d6bf --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/SyllabusHeader.tsx @@ -0,0 +1,18 @@ +// components/CourseSyllabus/SyllabusHeader.tsx +import React from "react"; + +import { XMarkIcon } from "@heroicons/react/24/outline"; +interface SyllabusHeaderProps { + onToggle: () => void; +} + +export const SyllabusHeader: React.FC = ({ onToggle }) => ( +
+

课程大纲

+ +
+); diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/index.ts b/apps/web/src/components/models/course/detail/CourseSyllabus/index.ts new file mode 100644 index 0000000..a294db3 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/index.ts @@ -0,0 +1 @@ +export * from "./CourseSyllabus"; diff --git a/apps/web/src/components/models/course/manage/CourseBasicForm.tsx b/apps/web/src/components/models/course/manage/CourseBasicForm.tsx deleted file mode 100644 index 2fa06ef..0000000 --- a/apps/web/src/components/models/course/manage/CourseBasicForm.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { SubmitHandler, useFormContext } from 'react-hook-form'; - -import { CourseFormData, useCourseEditor } from './CourseEditorContext'; -import { CourseLevel, CourseLevelLabel } from '@nice/common'; -import { FormInput } from '@web/src/components/presentation/form/FormInput'; -import { FormSelect } from '@web/src/components/presentation/form/FormSelect'; -import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField'; -import { convertToOptions } from '@nice/client'; - -export function CourseBasicForm() { - const { register, formState: { errors }, watch, handleSubmit } = useFormContext(); - - return ( -
- - - - - {/* */} - - ); -} - diff --git a/apps/web/src/components/models/course/manage/CourseEditor.tsx b/apps/web/src/components/models/course/manage/CourseEditor.tsx index bc2dfe8..bbb5152 100644 --- a/apps/web/src/components/models/course/manage/CourseEditor.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditor.tsx @@ -1,12 +1,13 @@ - -import { CourseBasicForm } from "./CourseBasicForm"; +import { CourseBasicForm } from "./CourseForms/CourseBasicForm"; import { CourseFormProvider } from "./CourseEditorContext"; import CourseEditorLayout from "./CourseEditorLayout"; export default function CourseEditor({ id }: { id?: string }) { - return - - - - -} \ No newline at end of file + return ( + + + + + + ); +} diff --git a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx index 221a435..08ae53e 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx @@ -3,48 +3,53 @@ import { useNavigate } from "react-router-dom"; import { DEFAULT_NAV_ITEMS } from "./navItems"; import CourseEditorHeader from "./CourseEditorHeader"; import { motion } from "framer-motion"; -import { NavItem } from "@nice/client" +import { NavItem } from "@nice/client"; import CourseEditorSidebar from "./CourseEditorSidebar"; interface CourseEditorLayoutProps { - children: ReactNode; + children: ReactNode; } - -export default function CourseEditorLayout({ children }: CourseEditorLayoutProps) { - const [isHovered, setIsHovered] = useState(false); - const [selectedSection, setSelectedSection] = useState(0); - const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); - const navigate = useNavigate(); - const handleNavigation = (item: NavItem, index: number) => { - setSelectedSection(index); - navigate(item.path); - }; - return ( -
- -
- - -
-
-

- {navItems[selectedSection]?.label} -

-
-
{children}
-
-
-
-
- ); -} \ No newline at end of file +export default function CourseEditorLayout({ + children, +}: CourseEditorLayoutProps) { + const [isHovered, setIsHovered] = useState(false); + const [selectedSection, setSelectedSection] = useState(0); + const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); + const navigate = useNavigate(); + const handleNavigation = (item: NavItem, index: number) => { + setSelectedSection(index); + navigate(item.path); + }; + return ( +
+ +
+ + +
+
+

+ {navItems[selectedSection]?.label} +

+
+
{children}
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx new file mode 100644 index 0000000..f9042ac --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx @@ -0,0 +1,50 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData, useCourseEditor } from "../CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/presentation/form/FormInput"; +import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; +import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; +import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; + +export function CourseBasicForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx new file mode 100644 index 0000000..557aa55 --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx @@ -0,0 +1,45 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData, useCourseEditor } from "../CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/presentation/form/FormInput"; +import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; +import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; +import { convertToOptions } from "@nice/client"; + +export function CourseBasicForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/manage/navItems.tsx b/apps/web/src/components/models/course/manage/navItems.tsx index 57ac873..b2342ae 100644 --- a/apps/web/src/components/models/course/manage/navItems.tsx +++ b/apps/web/src/components/models/course/manage/navItems.tsx @@ -1,28 +1,30 @@ -import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from '@heroicons/react/24/outline'; -import { NavItem } from '@nice/client'; +import { + AcademicCapIcon, + BookOpenIcon, + Cog6ToothIcon, + VideoCameraIcon, +} from "@heroicons/react/24/outline"; +import { NavItem } from "@nice/client"; export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ - { - - label: "课程概述", - icon: , - path: "/manage/overview" - }, - { - - label: "目标学员", - icon: , - path: "/manage/overview" - }, - { - - label: "课程内容", - icon: , - path: "/manage/content" - }, - { - label: "课程设置", - icon: , - path: "/manage/settings" - }, -]; \ No newline at end of file + { + label: "课程概述", + icon: , + path: "/manage/overview", + }, + { + label: "目标学员", + icon: , + path: "/manage/target", + }, + { + label: "课程内容", + icon: , + path: "/manage/content", + }, + { + label: "课程设置", + icon: , + path: "/manage/settings", + }, +]; diff --git a/apps/web/src/components/presentation/NavBar.tsx b/apps/web/src/components/presentation/NavBar.tsx new file mode 100644 index 0000000..c707bc0 --- /dev/null +++ b/apps/web/src/components/presentation/NavBar.tsx @@ -0,0 +1,58 @@ +// components/NavBar.tsx +import { motion } from "framer-motion"; +import React, { useState } from "react"; + +interface NavItem { + id: string; + icon?: React.ReactNode; + label: string; +} + +interface NavBarProps { + items: NavItem[]; + defaultSelected?: string; + onSelect?: (id: string) => void; +} + +export const NavBar = ({ items, defaultSelected, onSelect }: NavBarProps) => { + const [selected, setSelected] = useState(defaultSelected || items[0]?.id); + + const handleSelect = (id: string) => { + setSelected(id); + onSelect?.(id); + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/presentation/form/FormArrayField.tsx b/apps/web/src/components/presentation/form/FormArrayField.tsx index 93c5b27..a0f931c 100644 --- a/apps/web/src/components/presentation/form/FormArrayField.tsx +++ b/apps/web/src/components/presentation/form/FormArrayField.tsx @@ -1,89 +1,131 @@ -import { useFormContext } from 'react-hook-form'; -import { PlusIcon, XMarkIcon, Bars3Icon } from '@heroicons/react/24/outline'; -import { Reorder } from 'framer-motion'; -import { useState } from 'react'; -import FormError from './FormError'; -import { UUIDGenerator } from '@nice/common'; +import { useFormContext } from "react-hook-form"; +import { PlusIcon, XMarkIcon, Bars3Icon } from "@heroicons/react/24/outline"; +import { Reorder } from "framer-motion"; +import React, { useState } from "react"; +import FormError from "./FormError"; +import { UUIDGenerator } from "@nice/common"; interface ArrayFieldProps { - name: string; - label: string; - placeholder?: string; - addButtonText?: string; - inputProps?: React.InputHTMLAttributes; + name: string; + label: string; + placeholder?: string; + addButtonText?: string; + inputProps?: React.InputHTMLAttributes; } type ItemType = { id: string; value: string }; -const inputStyles = "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm"; -const buttonStyles = "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors "; -export function FormArrayField({ name, - label, - placeholder, - addButtonText = "添加项目", - inputProps = {} }: ArrayFieldProps) { - const { register, watch, setValue, formState: { errors }, trigger } = useFormContext(); - const [items, setItems] = useState(() => - (watch(name) as string[])?.map(value => ({ id: UUIDGenerator.generate(), value })) || [] - ); - const error = errors[name]?.message as string; +const inputStyles = + "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm"; +const buttonStyles = + "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors "; +export function FormArrayField({ + name, + label, + placeholder, + addButtonText = "添加项目", + inputProps = {}, +}: ArrayFieldProps) { + const { + register, + watch, + setValue, + formState: { errors }, + trigger, + } = useFormContext(); + const [items, setItems] = useState( + () => + (watch(name) as string[])?.map((value) => ({ + id: UUIDGenerator.generate(), + value, + })) || [] + ); + const error = errors[name]?.message as string; - const updateItems = (newItems: ItemType[]) => { - setItems(newItems); - setValue(name, newItems.map(item => item.value)); - }; - return ( -
- -
- - {items.map((item, index) => ( - -
-
- updateItems( - items.map(i => i.id === item.id ? { ...i, value: e.target.value } : i) - )} - onBlur={() => trigger(name)} - placeholder={placeholder} - className={inputStyles} - /> - {inputProps.maxLength && ( - - {inputProps.maxLength - (item.value?.length || 0)} - - )} -
- -
-
- ))} -
+ const updateItems = (newItems: ItemType[]) => { + setItems(newItems); + setValue( + name, + newItems.map((item) => item.value) + ); + }; + return ( +
+ +
+ + {items.map((item, index) => ( + +
+
+ + updateItems( + items.map((i) => + i.id === item.id + ? { + ...i, + value: e.target + .value, + } + : i + ) + ) + } + onBlur={() => trigger(name)} + placeholder={placeholder} + className={inputStyles} + /> + {inputProps.maxLength && ( + + {inputProps.maxLength - + (item.value?.length || 0)} + + )} +
+ +
+
+ ))} +
- -
- -
- ); -} \ No newline at end of file + +
+ +
+ ); +} diff --git a/apps/web/src/components/presentation/form/FormDynamicInputs.tsx b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx new file mode 100644 index 0000000..ec64367 --- /dev/null +++ b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx @@ -0,0 +1,154 @@ +// components/FormDynamicInputs.tsx +import { useFieldArray, useFormContext } from "react-hook-form"; +import { AnimatePresence, motion } from "framer-motion"; +import React, { useState } from "react"; +import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; +import FormError from "./FormError"; + +export interface DynamicFormInputProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + label: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + rows?: number; +} + +export function FormDynamicInputs({ + name, + label, + type = "text", + rows = 4, + className, + ...restProps +}: DynamicFormInputProps) { + const [focusedIndexes, setFocusedIndexes] = useState([]); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, + control, + } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + + const handleBlur = async (index: number) => { + setFocusedIndexes(focusedIndexes.filter((i) => i !== index)); + await trigger(`${name}.${index}`); + }; + + const values = watch(name) || []; + const fieldErrors = errors[name] as any; + + const inputClasses = ` + w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm + transition-all duration-300 ease-out placeholder:text-gray-400 + border-gray-300 focus:border-blue-500 focus:ring-blue-200 + `; + + const InputElement = type === "textarea" ? "textarea" : "input"; + + return ( +
+ + + + {fields.map((field, index) => ( + +
+ + setFocusedIndexes([ + ...focusedIndexes, + index, + ]) + } + onBlur={() => handleBlur(index)} + className={inputClasses} + /> + +
+ {values[index] && + focusedIndexes.includes(index) && ( + + )} + {values[index] && !fieldErrors?.[index] && ( + + )} +
+ + {index > 0 && ( + remove(index)} + className="absolute -right-2 -top-2 p-1 bg-red-500 rounded-full + text-white shadow-sm opacity-0 group-hover:opacity-100 + transition-opacity duration-200"> + + + )} +
+ {fieldErrors?.[index]?.message && ( + + )} +
+ ))} +
+ + append("")} + className="flex items-center gap-1 text-blue-500 hover:text-blue-600 + transition-colors px-4 py-2 rounded-lg hover:bg-blue-50"> + + 添加新{label} + +
+ ); +} diff --git a/apps/web/src/components/presentation/form/FormInput.tsx b/apps/web/src/components/presentation/form/FormInput.tsx index 85d273f..cbefe8e 100644 --- a/apps/web/src/components/presentation/form/FormInput.tsx +++ b/apps/web/src/components/presentation/form/FormInput.tsx @@ -1,88 +1,105 @@ -import { useFormContext } from 'react-hook-form'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useState } from 'react'; -import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import FormError from './FormError'; +import { useFormContext } from "react-hook-form"; +import { AnimatePresence, motion } from "framer-motion"; +import React, { useState } from "react"; +import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import FormError from "./FormError"; -export interface FormInputProps extends Omit, 'type'> { - name: string; - label: string; - type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local'; - rows?: number; +export interface FormInputProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + label: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + rows?: number; } export function FormInput({ - name, - label, - type = 'text', - rows = 4, - className, - ...restProps + name, + label, + type = "text", + rows = 4, + className, + ...restProps }: FormInputProps) { - const [isFocused, setIsFocused] = useState(false); - const { - register, - formState: { errors }, - watch, - setValue, - trigger, // Add trigger from useFormContext - } = useFormContext(); - const handleBlur = async () => { - setIsFocused(false); - await trigger(name); // Trigger validation for this field - }; - const value = watch(name); - const error = errors[name]?.message as string; - const isValid = value && !error; + const [isFocused, setIsFocused] = useState(false); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, // Add trigger from useFormContext + } = useFormContext(); + const handleBlur = async () => { + setIsFocused(false); + await trigger(name); // Trigger validation for this field + }; + const value = watch(name); + const error = errors[name]?.message as string; + const isValid = value && !error; - const inputClasses = ` + const inputClasses = ` w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm transition-all duration-300 ease-out placeholder:text-gray-400 - ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'} - ${isFocused ? 'ring-2 ring-opacity-50' : ''} - ${className || ''} + ${error ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-300 focus:border-blue-500 focus:ring-blue-200"} + ${isFocused ? "ring-2 ring-opacity-50" : ""} + ${className || ""} `; - const InputElement = type === 'textarea' ? 'textarea' : 'input'; + const InputElement = type === "textarea" ? "textarea" : "input"; - return ( -
-
- - {restProps.maxLength && ( - - {value?.length || 0}/{restProps.maxLength} - - )} -
+ return ( +
+
+ + {restProps.maxLength && ( + + {value?.length || 0}/{restProps.maxLength} + + )} +
-
- + setIsFocused(true)} + onBlur={handleBlur} + className={inputClasses} + /> - onFocus={() => setIsFocused(true)} - onBlur={handleBlur} - className={inputClasses} - /> - -
- {value && isFocused && ( - - )} - {isValid && } -
- -
-
- ); -} \ No newline at end of file +
+ {value && isFocused && ( + + )} + {isValid && ( + + )} +
+ +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx new file mode 100644 index 0000000..2dcaf4d --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx @@ -0,0 +1,32 @@ +import { SunIcon } from "@heroicons/react/24/solid"; +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; + +export default function Brightness() { + const { brightness, setBrightness } = useContext(VideoPlayerContext); + return ( + <> + {/* 亮度控制 */} +
+ +
+
+ + setBrightness(parseFloat(e.target.value)) + } + className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]" + /> +
+
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx new file mode 100644 index 0000000..b48b3e5 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx @@ -0,0 +1,29 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { + ArrowsPointingInIcon, + ArrowsPointingOutIcon, +} from "@heroicons/react/24/solid"; + +export default function FullScreen() { + const { videoRef } = useContext(VideoPlayerContext); + return ( + <> + + + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx new file mode 100644 index 0000000..fdff076 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { PauseIcon, PlayIcon } from "@heroicons/react/24/solid"; + +export default function Play() { + const { isPlaying, videoRef } = useContext(VideoPlayerContext); + + return ( + <> + + + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx new file mode 100644 index 0000000..2ece400 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx @@ -0,0 +1,62 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { AnimatePresence, motion } from "framer-motion"; +import { Cog6ToothIcon } from "@heroicons/react/24/solid"; + +export default function Setting() { + const { + isSettingsOpen, + setIsSettingsOpen, + resolution, + setResolution, + resolutions, + } = useContext(VideoPlayerContext); + + return ( + <> +
+ + + + {isSettingsOpen && ( + + {/* 清晰度选择器 */} +
+
+ 清晰度 +
+ {resolutions.map((res) => ( + + ))} +
+
+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx new file mode 100644 index 0000000..8489a50 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx @@ -0,0 +1,59 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { ChevronUpDownIcon } from "@heroicons/react/24/solid"; +import { PlaybackSpeed } from "../type"; + +export default function Speed() { + const { + setIsSpeedOpen, + isSpeedOpen, + playbackSpeed, + setPlaybackSpeed, + videoRef, + } = useContext(VideoPlayerContext); + return ( + <> +
+ + {isSpeedOpen && ( +
+
+
+ {[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map( + (speed) => ( + + ) + )} +
+
+
+ )} +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx new file mode 100644 index 0000000..f07c31a --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx @@ -0,0 +1,64 @@ +import React, { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { motion } from "framer-motion"; + +export default function TimeLine() { + const { + currentTime, + duration, + progressRef, + setIsDragging, + videoRef, + isDragging, + isHovering, + } = useContext(VideoPlayerContext); + const handleProgressClick = (e: React.MouseEvent) => { + if (!videoRef.current || !progressRef.current) return; + + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + videoRef.current.currentTime = percent * videoRef.current.duration; + }; + return ( + <> +
{ + setIsDragging(true); + handleProgressClick(e); + }}> + {/* 背景条 */} +
+ {/* 播放进度 */} + + {/* 进度球 */} + + {/* 预览进度 */} + +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx new file mode 100644 index 0000000..bb25b68 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx @@ -0,0 +1,43 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/24/solid"; + +export default function Volume() { + const { isMuted, setIsMuted, volume, setVolume, videoRef } = + useContext(VideoPlayerContext); + return ( + <> + {/* 音量控制 */} +
+ +
+
+ { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (videoRef.current) { + videoRef.current.volume = newVolume; + } + }} + className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]" + /> +
+
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/index.ts b/apps/web/src/components/presentation/video-player/ControlButtons/index.ts new file mode 100644 index 0000000..35cbbf0 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/index.ts @@ -0,0 +1,4 @@ +export * from "./Brightness"; +export * from "./Volume"; +export * from "./Speed"; +export * from "./Play"; diff --git a/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx index 4facd0c..e3f051f 100644 --- a/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx +++ b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import Hls from "hls.js"; import { motion, AnimatePresence } from "framer-motion"; -import "plyr/dist/plyr.css"; + import { VideoPlayerContext } from "./VideoPlayer"; export const LoadingOverlay = () => { const { loadingProgress } = useContext(VideoPlayerContext); diff --git a/apps/web/src/components/presentation/video-player/VideoControls.tsx b/apps/web/src/components/presentation/video-player/VideoControls.tsx index 692f4bb..73d4547 100644 --- a/apps/web/src/components/presentation/video-player/VideoControls.tsx +++ b/apps/web/src/components/presentation/video-player/VideoControls.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useContext, useState } from "react"; -import Hls from "hls.js"; import { motion, AnimatePresence } from "framer-motion"; import { PlayIcon, @@ -9,11 +8,20 @@ import { Cog6ToothIcon, ArrowsPointingOutIcon, ArrowsPointingInIcon, + ChevronUpDownIcon, + SunIcon, } from "@heroicons/react/24/solid"; -import "plyr/dist/plyr.css"; + import { VideoPlayerContext } from "./VideoPlayer"; import { formatTime } from "./utlis"; import { PlaybackSpeed } from "./type"; +import Volume from "./ControlButtons/Volume"; +import Brightness from "./ControlButtons/Brightness"; +import Speed from "./ControlButtons/Speed"; +import Play from "./ControlButtons/Play"; +import Setting from "./ControlButtons/Setting"; +import FullScreen from "./ControlButtons/FullScreen"; +import TimeLine from "./ControlButtons/TimeLine"; export const Controls = () => { const { @@ -27,6 +35,8 @@ export const Controls = () => { isReady, setIsReady, isPlaying, + setIsSpeedOpen, + isSpeedOpen, setIsPlaying, bufferingState, @@ -46,16 +56,12 @@ export const Controls = () => { isDragging, setIsDragging, isHovering, + isBrightnessOpen, + setIsBrightnessOpen, setIsHovering, progressRef, } = useContext(VideoPlayerContext); - const handleProgressClick = (e: React.MouseEvent) => { - if (!videoRef.current || !progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - videoRef.current.currentTime = percent * videoRef.current.duration; - }; // 控制栏显示逻辑 useEffect(() => { let timer: number; @@ -84,190 +90,35 @@ export const Controls = () => { }} className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4"> {/* 进度条 */} -
{ - setIsDragging(true); - handleProgressClick(e); - }}> - {/* 背景条 */} -
- {/* 播放进度 */} - - {/* 进度球 */} - - {/* 预览进度 */} - -
+ {/* 控制按钮区域 */}
{/* 播放/暂停按钮 */} - - - {/* 音量控制 */} -
- -
-
- { - const newVolume = parseFloat( - e.target.value - ); - setVolume(newVolume); - if (videoRef.current) { - videoRef.current.volume = newVolume; - } - }} - className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]" - /> -
-
-
+ {/* 时间显示 */} {duration && ( - + {formatTime(currentTime)} / {formatTime(duration)} )}
{/* 右侧控制按钮 */}
- {/* 设置按钮 */} -
- - {/* 设置菜单 */} - - {isSettingsOpen && ( - - {/* 倍速选择 */} -
-

- 播放速度 -

- {[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map( - (speed) => ( - - ) - )} -
+ {/* 音量 */} + + {/* 亮度 */} + - {/* 亮度调节 */} -
-

- 亮度 -

- - setBrightness( - parseFloat(e.target.value) - ) - } - className="w-full accent-primary-500" - /> -
-
- )} -
-
+ {/* 倍速控制 */} + + {/* 设置按钮 */} + {/* 全屏按钮 */} - +
diff --git a/apps/web/src/components/presentation/video-player/VideoScreen.tsx b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx similarity index 87% rename from apps/web/src/components/presentation/video-player/VideoScreen.tsx rename to apps/web/src/components/presentation/video-player/VideoDisplay.tsx index 3c8f7c4..8b89885 100644 --- a/apps/web/src/components/presentation/video-player/VideoScreen.tsx +++ b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx @@ -1,61 +1,38 @@ -// VideoPlayer.tsx import React, { useContext, useEffect, useRef, useState } from "react"; import Hls from "hls.js"; -import { motion, AnimatePresence } from "framer-motion"; -import "plyr/dist/plyr.css"; + import { VideoPlayerContext } from "./VideoPlayer"; -interface VideoScreenProps { +interface VideoDisplayProps { autoPlay?: boolean; - // className?: string; - // qualities?: { label: string; value: string }[]; - // onQualityChange?: (quality: string) => void; } -export const VideoScreen: React.FC = ({ +export const VideoDisplay: React.FC = ({ autoPlay = false, }) => { const { src, poster, onError, - showControls, - setShowControls, - isSettingsOpen, - setIsSettingsOpen, - playbackSpeed, - setPlaybackSpeed, videoRef, - isReady, setIsReady, - isPlaying, setIsPlaying, - error, setError, - bufferingState, setBufferingState, - volume, - setVolume, isMuted, - setIsMuted, - loadingProgress, setLoadingProgress, - currentTime, setCurrentTime, - duration, setDuration, brightness, - setBrightness, isDragging, setIsDragging, - isHovering, - setIsHovering, progressRef, + resolution, + setResolutions, } = useContext(VideoPlayerContext); - // 处理进度条拖拽 + // 处理进度条拖拽 const handleProgressDrag = (e: MouseEvent) => { if (!isDragging || !videoRef.current || !progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); const percent = Math.max( 0, @@ -68,23 +45,19 @@ export const VideoScreen: React.FC = ({ useEffect(() => { const handleMouseUp = () => setIsDragging(false); const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e); - if (isDragging) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); } - return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [isDragging]); - // 添加控制栏组件 - + // 初始化 HLS 和事件监听 useEffect(() => { let hls: Hls; - const initializeHls = async () => { if (!videoRef.current) return; @@ -98,6 +71,10 @@ export const VideoScreen: React.FC = ({ if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { videoRef.current.src = src; setIsReady(true); + + // 设置视频时长 + setDuration(videoRef.current.duration); + if (autoPlay) { try { await videoRef.current.play(); @@ -128,6 +105,10 @@ export const VideoScreen: React.FC = ({ hls.on(Hls.Events.MANIFEST_PARSED, async () => { setIsReady(true); + + // 设置视频时长 + setDuration(videoRef.current?.duration || 0); + if (autoPlay && videoRef.current) { try { await videoRef.current.play(); @@ -141,7 +122,6 @@ export const VideoScreen: React.FC = ({ hls.on(Hls.Events.BUFFER_APPENDING, () => { setBufferingState(true); }); - hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => { setBufferingState(false); if (data.stats) { @@ -150,8 +130,8 @@ export const VideoScreen: React.FC = ({ setLoadingProgress(Math.round(progress)); } }); - let networkError; let fatalError; + let networkError; hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { switch (data.type) { @@ -178,20 +158,34 @@ export const VideoScreen: React.FC = ({ } }); }; - // Event handlers + + // 事件处理 const handlePlay = () => setIsPlaying(true); const handlePause = () => setIsPlaying(false); const handleEnded = () => setIsPlaying(false); const handleWaiting = () => setBufferingState(true); const handlePlaying = () => setBufferingState(false); + const handleLoadedMetadata = () => { + if (videoRef.current) { + // 设置视频时长 + setDuration(videoRef.current.duration); + } + }; + if (videoRef.current) { videoRef.current.addEventListener("play", handlePlay); videoRef.current.addEventListener("pause", handlePause); videoRef.current.addEventListener("ended", handleEnded); videoRef.current.addEventListener("waiting", handleWaiting); videoRef.current.addEventListener("playing", handlePlaying); + videoRef.current.addEventListener( + "loadedmetadata", + handleLoadedMetadata + ); } + initializeHls(); + return () => { if (videoRef.current) { videoRef.current.removeEventListener("play", handlePlay); @@ -199,6 +193,10 @@ export const VideoScreen: React.FC = ({ videoRef.current.removeEventListener("ended", handleEnded); videoRef.current.removeEventListener("waiting", handleWaiting); videoRef.current.removeEventListener("playing", handlePlaying); + videoRef.current.removeEventListener( + "loadedmetadata", + handleLoadedMetadata + ); } if (hls) { hls.destroy(); @@ -218,7 +216,6 @@ export const VideoScreen: React.FC = ({ onTimeUpdate={() => { if (videoRef.current) { setCurrentTime(videoRef.current.currentTime); - setDuration(videoRef.current.duration); } }} /> diff --git a/apps/web/src/components/presentation/video-player/VideoPlayer.tsx b/apps/web/src/components/presentation/video-player/VideoPlayer.tsx index 90fc0bf..4a86f4b 100644 --- a/apps/web/src/components/presentation/video-player/VideoPlayer.tsx +++ b/apps/web/src/components/presentation/video-player/VideoPlayer.tsx @@ -1,6 +1,7 @@ import React, { createContext, ReactNode, useRef, useState } from "react"; import { PlaybackSpeed } from "./type"; import VideoPlayerLayout from "./VideoPlayerLayout"; +import { Resolution } from "./interface"; interface VideoPlayerContextType { src: string; @@ -38,6 +39,14 @@ interface VideoPlayerContextType { isHovering: boolean; setIsHovering: React.Dispatch>; progressRef: React.RefObject; + isSpeedOpen: boolean; + setIsSpeedOpen: React.Dispatch>; + isBrightnessOpen: boolean; + setIsBrightnessOpen: React.Dispatch>; + resolution: number; + setResolution: React.Dispatch>; + resolutions: Resolution[]; + setResolutions: React.Dispatch>; } export const VideoPlayerContext = createContext( null @@ -66,10 +75,13 @@ export function VideoPlayer({ const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [brightness, setBrightness] = useState(1); + const [resolution, setResolution] = useState(-1); + const [resolutions, setResolutions] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isHovering, setIsHovering] = useState(false); const progressRef = useRef(null); - + const [isSpeedOpen, setIsSpeedOpen] = useState(false); + const [isBrightnessOpen, setIsBrightnessOpen] = useState(false); return ( diff --git a/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx b/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx index 0da46a8..5bcbb51 100644 --- a/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx +++ b/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx @@ -2,7 +2,7 @@ import { useContext } from "react"; import { VideoPlayerContext } from "./VideoPlayer"; import Controls from "./VideoControls"; import { AnimatePresence } from "framer-motion"; -import { VideoScreen } from "./VideoScreen"; +import { VideoDisplay } from "./VideoDisplay"; import LoadingOverlay from "./LoadingOverlay"; export default function VideoPlayerLayout() { @@ -17,14 +17,15 @@ export default function VideoPlayerLayout() { return ( <>
{ setIsHovering(true); setShowControls(true); }}> {!isReady &&
123
} {!isReady && } - + {(showControls || isDragging) && } diff --git a/apps/web/src/components/presentation/video-player/interface.ts b/apps/web/src/components/presentation/video-player/interface.ts new file mode 100644 index 0000000..5579328 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/interface.ts @@ -0,0 +1,10 @@ +// 定义清晰度选项的类型 +export interface Resolution { + id: number; + height: number; + width: number; + bitrate: number; + label: string; + url?: string; // 可选:清晰度对应的视频URL + active?: boolean; // 可选:是否是当前激活的清晰度 +} diff --git a/apps/web/src/components/presentation/video-player/utlis.ts b/apps/web/src/components/presentation/video-player/utlis.ts index edd8a74..3bee59a 100644 --- a/apps/web/src/components/presentation/video-player/utlis.ts +++ b/apps/web/src/components/presentation/video-player/utlis.ts @@ -1,4 +1,4 @@ -export const formatTime = (seconds: number): string => { +export const formatTime = (seconds: number = 0): string => { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 102492c..6e90447 100755 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -107,37 +107,3 @@ .custom-table .ant-table-tbody > tr:last-child > td { border-bottom: none; /* 去除最后一行的底部边框 */ } -/* 自定义 Plyr 样式 */ -.plyr--full-ui input[type="range"] { - color: #ff0000; /* YouTube 红色 */ -} - -.plyr__control--overlaid { - background: rgba(255, 0, 0, 0.8); -} - -.plyr--video .plyr__control:hover { - background: #ff0000; -} - -.plyr--full-ui input[type="range"]::-webkit-slider-thumb { - background: #ff0000; -} - -.plyr--full-ui input[type="range"]::-moz-range-thumb { - background: #ff0000; -} - -.plyr--full-ui input[type="range"]::-ms-thumb { - background: #ff0000; -} - -/* 缓冲条样式 */ -.plyr__progress__buffer { - color: rgba(255, 255, 255, 0.25); -} - -/* 控制栏背景 */ -.plyr--video .plyr__controls { - background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.7)); -} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e55f26a..a2ef037 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -19,6 +19,7 @@ import StudentCoursesPage from "../app/main/courses/student/page"; import InstructorCoursesPage from "../app/main/courses/instructor/page"; import HomePage from "../app/main/home/page"; import { CourseDetailPage } from "../app/main/course/detail/page"; + interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; @@ -85,6 +86,20 @@ export const routes: CustomRouteObject[] = [ { path: ":id?/manage", // 使用 ? 表示 id 参数是可选的 element: , + children: [ + { + index: true, // This will make :id?/manage the default route + element: , + }, + { + path: "overview", + element: , // You might want to create a specific overview component + }, + { + path: "target", + element: , // Create a specific target page component + }, + ], }, { path: ":id?/detail", // 使用 ? 表示 id 参数是可选的 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d183884..5abfd1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,9 +341,6 @@ importers: mitt: specifier: ^3.0.1 version: 3.0.1 - plyr-react: - specifier: ^5.3.0 - version: 5.3.0(plyr@3.7.8)(react@18.2.0) react: specifier: 18.2.0 version: 18.2.0 @@ -3601,9 +3598,6 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - core-js@3.39.0: - resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3666,9 +3660,6 @@ packages: custom-error-instance@2.1.1: resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} - custom-event-polyfill@1.0.7: - resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==} - date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -4863,9 +4854,6 @@ packages: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} - loadjs@4.3.0: - resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==} - locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -5379,19 +5367,6 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - plyr-react@5.3.0: - resolution: {integrity: sha512-m36/HrpHwg1N2rq3E31E8/kpAH55vk6qHUg17MG4uu9jbWYxnkN39lLmZQwxW7/qpDPfW5aGUJ6R3u23V0R3zA==} - engines: {node: '>=16'} - peerDependencies: - plyr: ^3.7.7 - react: '>=16.8' - peerDependenciesMeta: - react: - optional: true - - plyr@3.7.8: - resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==} - possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -5535,9 +5510,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - rangetouch@2.0.1: - resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} - raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -5770,15 +5742,6 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' - react-aptor@2.0.0: - resolution: {integrity: sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==} - engines: {node: '>=12.7.0'} - peerDependencies: - react: '>=16.8' - peerDependenciesMeta: - react: - optional: true - react-beautiful-dnd@13.1.1: resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' @@ -6587,9 +6550,6 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url-polyfill@1.1.12: - resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==} - use-memo-one@1.1.3: resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} peerDependencies: @@ -10523,8 +10483,6 @@ snapshots: dependencies: toggle-selection: 1.0.6 - core-js@3.39.0: {} - core-util-is@1.0.3: {} cors@2.8.5: @@ -10605,8 +10563,6 @@ snapshots: custom-error-instance@2.1.1: {} - custom-event-polyfill@1.0.7: {} - date-fns@2.30.0: dependencies: '@babel/runtime': 7.25.0 @@ -12098,8 +12054,6 @@ snapshots: loader-runner@4.3.0: {} - loadjs@4.3.0: {} - locate-path@3.0.0: dependencies: p-locate: 3.0.0 @@ -12546,21 +12500,6 @@ snapshots: pluralize@8.0.0: {} - plyr-react@5.3.0(plyr@3.7.8)(react@18.2.0): - dependencies: - plyr: 3.7.8 - react-aptor: 2.0.0(react@18.2.0) - optionalDependencies: - react: 18.2.0 - - plyr@3.7.8: - dependencies: - core-js: 3.39.0 - custom-event-polyfill: 1.0.7 - loadjs: 4.3.0 - rangetouch: 2.0.1 - url-polyfill: 1.1.12 - possible-typed-array-names@1.0.0: {} postcss-import@15.1.0(postcss@8.4.49): @@ -12686,8 +12625,6 @@ snapshots: range-parser@1.2.1: {} - rangetouch@2.0.1: {} - raw-body@2.5.2: dependencies: bytes: 3.1.2 @@ -13016,10 +12953,6 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-aptor@2.0.0(react@18.2.0): - optionalDependencies: - react: 18.2.0 - react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.25.0 @@ -13944,8 +13877,6 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - url-polyfill@1.1.12: {} - use-memo-one@1.1.3(react@18.2.0): dependencies: react: 18.2.0 From b1c27943cb49d1d45b836b4762d07d5f4d95f1cb Mon Sep 17 00:00:00 2001 From: ditiqi Date: Tue, 21 Jan 2025 20:05:42 +0800 Subject: [PATCH 2/2] add --- .../models/resource/pipe/resource.pipeline.ts | 153 ++++---- .../resource/processor/ImageProcessor.ts | 34 +- .../resource/processor/VideoProcessor.ts | 341 ++++++++++-------- apps/server/src/trpc/trpc.service.ts | 71 ++-- apps/web/src/app/main/course/editor/page.tsx | 8 +- .../models/course/list/course-list.tsx | 94 +++-- .../models/course/manage/CourseEditor.tsx | 14 +- .../course/manage/CourseEditorContext.tsx | 201 ++++++----- .../course/manage/CourseEditorHeader.tsx | 1 + .../course/manage/CourseEditorLayout.tsx | 1 + .../manage/CourseForms/CourseBasicForm.tsx | 9 +- .../manage/CourseForms/CourseContentForm.tsx | 49 +++ .../course/manage/CourseForms/CourseForm.tsx | 23 ++ ...getForm copy.tsx => CourseSettingForm.tsx} | 4 +- .../manage/CourseForms/CourseTargetForm.tsx | 46 +++ .../components/models/course/manage/enum.ts | 6 + .../models/course/manage/navItems.tsx | 72 ++-- .../presentation/form/FormDynamicInputs.tsx | 70 ++-- apps/web/src/routes/index.tsx | 16 +- 19 files changed, 702 insertions(+), 511 deletions(-) create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseContentForm.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx rename apps/web/src/components/models/course/manage/CourseForms/{CourseTargetForm copy.tsx => CourseSettingForm.tsx} (88%) create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx create mode 100644 apps/web/src/components/models/course/manage/enum.ts diff --git a/apps/server/src/models/resource/pipe/resource.pipeline.ts b/apps/server/src/models/resource/pipe/resource.pipeline.ts index ce132da..2c5d98e 100644 --- a/apps/server/src/models/resource/pipe/resource.pipeline.ts +++ b/apps/server/src/models/resource/pipe/resource.pipeline.ts @@ -1,84 +1,85 @@ -import { PrismaClient, Resource } from '@prisma/client' -import { ProcessResult, ResourceProcessor } from '../types' -import { db, ResourceStatus } from '@nice/common' +import { PrismaClient, Resource } from '@prisma/client'; +import { ProcessResult, ResourceProcessor } from '../types'; +import { db, ResourceStatus } from '@nice/common'; import { Logger } from '@nestjs/common'; - // Pipeline 类 export class ResourceProcessingPipeline { - private processors: ResourceProcessor[] = [] - private logger = new Logger(ResourceProcessingPipeline.name); + private processors: ResourceProcessor[] = []; + private logger = new Logger(ResourceProcessingPipeline.name); - constructor() { } + constructor() {} - // 添加处理器 - addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { - this.processors.push(processor) - return this - } - - // 执行处理管道 - async execute(resource: Resource): Promise { - let currentResource = resource - try { - this.logger.log(`开始处理资源: ${resource.id}`) - - currentResource = await this.updateProcessStatus( - resource.id, - ResourceStatus.PROCESSING - ) - this.logger.log(`资源状态已更新为处理中`) - - for (const processor of this.processors) { - const processorName = processor.constructor.name - this.logger.log(`开始执行处理器: ${processorName}`) - - currentResource = await this.updateProcessStatus( - currentResource.id, - processor.constructor.name as ResourceStatus - ) - - currentResource = await processor.process(currentResource) - this.logger.log(`处理器 ${processorName} 执行完成`) - - currentResource = await db.resource.update({ - where: { id: currentResource.id }, - data: currentResource - }) - } - - currentResource = await this.updateProcessStatus( - currentResource.id, - ResourceStatus.PROCESSED - ) - this.logger.log(`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`) - - return { - success: true, - resource: currentResource - } - } catch (error) { - this.logger.error(`资源 ${resource.id} 处理失败:`, error) - - currentResource = await this.updateProcessStatus( - currentResource.id, - ResourceStatus.PROCESS_FAILED - ) - - return { - success: false, - resource: currentResource, - error: error as Error - } - } - } - private async updateProcessStatus( - resourceId: string, - status: ResourceStatus - ): Promise { - return db.resource.update({ - where: { id: resourceId }, - data: { status } - }) + // 添加处理器 + addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { + this.processors.push(processor); + return this; + } + + // 执行处理管道 + async execute(resource: Resource): Promise { + let currentResource = resource; + try { + this.logger.log(`开始处理资源: ${resource.id}`); + + currentResource = await this.updateProcessStatus( + resource.id, + ResourceStatus.PROCESSING, + ); + this.logger.log(`资源状态已更新为处理中`); + + for (const processor of this.processors) { + const processorName = processor.constructor.name; + this.logger.log(`开始执行处理器: ${processorName}`); + + currentResource = await this.updateProcessStatus( + currentResource.id, + processor.constructor.name as ResourceStatus, + ); + + currentResource = await processor.process(currentResource); + this.logger.log(`处理器 ${processorName} 执行完成`); + + currentResource = await db.resource.update({ + where: { id: currentResource.id }, + data: currentResource, + }); + } + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESSED, + ); + this.logger.log( + `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`, + ); + + return { + success: true, + resource: currentResource, + }; + } catch (error) { + this.logger.error(`资源 ${resource.id} 处理失败:`, error); + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESS_FAILED, + ); + + return { + success: false, + resource: currentResource, + error: error as Error, + }; } + } + private async updateProcessStatus( + resourceId: string, + status: ResourceStatus, + ): Promise { + return db.resource.update({ + where: { id: resourceId }, + data: { status }, + }); + } } diff --git a/apps/server/src/models/resource/processor/ImageProcessor.ts b/apps/server/src/models/resource/processor/ImageProcessor.ts index 71cc56e..ea01e75 100644 --- a/apps/server/src/models/resource/processor/ImageProcessor.ts +++ b/apps/server/src/models/resource/processor/ImageProcessor.ts @@ -1,12 +1,14 @@ -import path from "path"; +import path from 'path'; import sharp from 'sharp'; -import { FileMetadata, ImageMetadata, ResourceProcessor } from "../types"; -import { Resource, ResourceStatus, db } from "@nice/common"; -import { getUploadFilePath } from "@server/utils/file"; -import { BaseProcessor } from "./BaseProcessor"; +import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; +import { BaseProcessor } from './BaseProcessor'; export class ImageProcessor extends BaseProcessor { - constructor() { super() } + constructor() { + super(); + } async process(resource: Resource): Promise { const { url } = resource; @@ -23,13 +25,16 @@ export class ImageProcessor extends BaseProcessor { throw new Error(`Failed to get metadata for image: ${url}`); } // Create WebP compressed version - const compressedDir = this.createOutputDir(filepath, "compressed") - const compressedPath = path.join(compressedDir, `${path.basename(filepath, path.extname(filepath))}.webp`); + const compressedDir = this.createOutputDir(filepath, 'compressed'); + const compressedPath = path.join( + compressedDir, + `${path.basename(filepath, path.extname(filepath))}.webp`, + ); await image .webp({ quality: 80, lossless: false, - effort: 5 // Range 0-6, higher means slower but better compression + effort: 5, // Range 0-6, higher means slower but better compression }) .toFile(compressedPath); const imageMeta: ImageMetadata = { @@ -38,15 +43,15 @@ export class ImageProcessor extends BaseProcessor { orientation: metadata.orientation, space: metadata.space, hasAlpha: metadata.hasAlpha, - } + }; const updatedResource = await db.resource.update({ where: { id: resource.id }, data: { metadata: { ...originMeta, - ...imageMeta - } - } + ...imageMeta, + }, + }, }); return updatedResource; @@ -54,5 +59,4 @@ export class ImageProcessor extends BaseProcessor { throw new Error(`Failed to process image: ${error.message}`); } } - -} \ No newline at end of file +} diff --git a/apps/server/src/models/resource/processor/VideoProcessor.ts b/apps/server/src/models/resource/processor/VideoProcessor.ts index 38d0ae2..7853f5e 100644 --- a/apps/server/src/models/resource/processor/VideoProcessor.ts +++ b/apps/server/src/models/resource/processor/VideoProcessor.ts @@ -1,167 +1,190 @@ -import path, { dirname } from "path"; +import path, { dirname } from 'path'; import ffmpeg from 'fluent-ffmpeg'; -import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; -import { Resource, ResourceStatus, db } from "@nice/common"; -import { getUploadFilePath } from "@server/utils/file"; +import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; import fs from 'fs/promises'; import sharp from 'sharp'; -import { BaseProcessor } from "./BaseProcessor"; +import { BaseProcessor } from './BaseProcessor'; export class VideoProcessor extends BaseProcessor { - constructor() { super() } - async process(resource: Resource): Promise { - const { url} = resource; - const filepath = getUploadFilePath(url); - this.logger.log(`Processing video for resource ID: ${resource.id}, File ID: ${url}`); + constructor() { + super(); + } + async process(resource: Resource): Promise { + const { url } = resource; + const filepath = getUploadFilePath(url); + this.logger.log( + `Processing video for resource ID: ${resource.id}, File ID: ${url}`, + ); - const originMeta = resource.metadata as unknown as FileMetadata; - if (!originMeta.mimeType?.startsWith('video/')) { - this.logger.log(`Skipping non-video resource: ${resource.id}`); - return resource; + const originMeta = resource.metadata as unknown as FileMetadata; + if (!originMeta.mimeType?.startsWith('video/')) { + this.logger.log(`Skipping non-video resource: ${resource.id}`); + return resource; + } + + try { + const streamDir = this.createOutputDir(filepath, 'stream'); + + const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ + this.generateM3U8Stream(filepath, streamDir), + this.getVideoMetadata(filepath), + this.generateVideoCover(filepath, dirname(filepath)), + ]); + const videoMeta: VideoMetadata = { + ...videoMetadata, + coverUrl: coverUrl, + }; + const updatedResource = await db.resource.update({ + where: { id: resource.id }, + data: { + metadata: { + ...originMeta, + ...videoMeta, + }, + }, + }); + this.logger.log( + `Successfully processed video for resource ID: ${resource.id}`, + ); + return updatedResource; + } catch (error: any) { + this.logger.error( + `Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`, + ); + throw new Error(`Failed to process video: ${error.message}`); + } + } + private async generateVideoCover( + filepath: string, + outputDir: string, + ): Promise { + this.logger.log(`Generating video cover for: ${filepath}`); + const jpgCoverPath = path.join(outputDir, 'cover.jpg'); + const webpCoverPath = path.join(outputDir, 'cover.webp'); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .on('end', async () => { + try { + // 使用 Sharp 将 JPG 转换为 WebP + await sharp(jpgCoverPath) + .webp({ quality: 80 }) // 设置 WebP 压缩质量 + .toFile(webpCoverPath); + + // 删除临时 JPG 文件 + await fs.unlink(jpgCoverPath); + this.logger.log(`Video cover generated at: ${webpCoverPath}`); + resolve(path.basename(webpCoverPath)); + } catch (error: any) { + this.logger.error( + `Error converting cover to WebP: ${error.message}`, + ); + reject(error); + } + }) + .on('error', (err) => { + this.logger.error(`Error generating video cover: ${err.message}`); + reject(err); + }) + .screenshots({ + count: 1, + folder: outputDir, + filename: 'cover.jpg', + size: '640x360', + }); + }); + } + private async getVideoDuration(filepath: string): Promise { + this.logger.log(`Getting video duration for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video duration: ${err.message}`); + reject(err); + return; } - - try { - const streamDir = this.createOutputDir(filepath, 'stream'); - const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ - this.generateM3U8Stream(filepath, streamDir), - this.getVideoMetadata(filepath), - this.generateVideoCover(filepath, dirname(filepath)) - ]); - - const videoMeta: VideoMetadata = { - ...videoMetadata, - coverUrl: coverUrl, - }; - - const updatedResource = await db.resource.update({ - where: { id: resource.id }, - data: { - metadata: { - ...originMeta, - ...videoMeta, - }, - }, - }); - - this.logger.log(`Successfully processed video for resource ID: ${resource.id}`); - return updatedResource; - } catch (error: any) { - this.logger.error(`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`); - throw new Error(`Failed to process video: ${error.message}`); + const duration = metadata.format.duration || 0; + this.logger.log(`Video duration: ${duration} seconds`); + resolve(duration); + }); + }); + } + private async generateM3U8Stream( + filepath: string, + outputDir: string, + ): Promise { + const m3u8Path = path.join(outputDir, 'index.m3u8'); + this.logger.log( + `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`, + ); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .outputOptions([ + // Improved video encoding settings + '-c:v libx264', + '-preset medium', // Balance between encoding speed and compression + '-crf 23', // Constant Rate Factor for quality + '-profile:v high', // Higher profile for better compression + '-level:v 4.1', // Updated level for better compatibility + // Parallel processing and performance + '-threads 0', // Auto-detect optimal thread count + '-x264-params keyint=48:min-keyint=48', // More precise GOP control + // HLS specific optimizations + '-hls_time 4', // Shorter segment duration for better adaptive streaming + '-hls_list_size 0', // Keep all segments in playlist + '-hls_flags independent_segments+delete_segments', // Allow segment cleanup + // Additional encoding optimizations + '-sc_threshold 0', // Disable scene change detection for more consistent segments + '-max_muxing_queue_size 1024', // Increase muxing queue size + // Output format + '-f hls', + ]) + .output(m3u8Path) + .on('start', (commandLine) => { + this.logger.log(`Starting ffmpeg with command: ${commandLine}`); + }) + .on('end', () => { + this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); + resolve(m3u8Path); + }) + .on('error', (err) => { + const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; + this.logger.error(errorMessage); + reject(new Error(errorMessage)); + }) + .run(); + }); + } + private async getVideoMetadata( + filepath: string, + ): Promise> { + this.logger.log(`Getting video metadata for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video metadata: ${err.message}`); + reject(err); + return; } - } - private async generateVideoCover(filepath: string, outputDir: string): Promise { - this.logger.log(`Generating video cover for: ${filepath}`); - const jpgCoverPath = path.join(outputDir, 'cover.jpg'); - const webpCoverPath = path.join(outputDir, 'cover.webp'); - return new Promise((resolve, reject) => { - ffmpeg(filepath) - .on('end', async () => { - try { - // 使用 Sharp 将 JPG 转换为 WebP - await sharp(jpgCoverPath) - .webp({ quality: 80 }) // 设置 WebP 压缩质量 - .toFile(webpCoverPath); - - // 删除临时 JPG 文件 - await fs.unlink(jpgCoverPath); - - this.logger.log(`Video cover generated at: ${webpCoverPath}`); - resolve(path.basename(webpCoverPath)); - } catch (error: any) { - this.logger.error(`Error converting cover to WebP: ${error.message}`); - reject(error); - } - }) - .on('error', (err) => { - this.logger.error(`Error generating video cover: ${err.message}`); - reject(err); - }) - .screenshots({ - count: 1, - folder: outputDir, - filename: 'cover.jpg', - size: '640x360' - }); - }); - } - private async getVideoDuration(filepath: string): Promise { - this.logger.log(`Getting video duration for file: ${filepath}`); - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filepath, (err, metadata) => { - if (err) { - this.logger.error(`Error getting video duration: ${err.message}`); - reject(err); - return; - } - const duration = metadata.format.duration || 0; - this.logger.log(`Video duration: ${duration} seconds`); - resolve(duration); - }); - }); - } - private async generateM3U8Stream(filepath: string, outputDir: string): Promise { - const m3u8Path = path.join(outputDir, 'index.m3u8'); - this.logger.log(`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`); - return new Promise((resolve, reject) => { - ffmpeg(filepath) - .outputOptions([ - // Improved video encoding settings - '-c:v libx264', - '-preset medium', // Balance between encoding speed and compression - '-crf 23', // Constant Rate Factor for quality - '-profile:v high', // Higher profile for better compression - '-level:v 4.1', // Updated level for better compatibility - // Parallel processing and performance - '-threads 0', // Auto-detect optimal thread count - '-x264-params keyint=48:min-keyint=48', // More precise GOP control - // HLS specific optimizations - '-hls_time 4', // Shorter segment duration for better adaptive streaming - '-hls_list_size 0', // Keep all segments in playlist - '-hls_flags independent_segments+delete_segments', // Allow segment cleanup - // Additional encoding optimizations - '-sc_threshold 0', // Disable scene change detection for more consistent segments - '-max_muxing_queue_size 1024', // Increase muxing queue size - // Output format - '-f hls', - ]) - .output(m3u8Path) - .on('start', (commandLine) => { - this.logger.log(`Starting ffmpeg with command: ${commandLine}`); - }) - .on('end', () => { - this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); - resolve(m3u8Path); - }) - .on('error', (err) => { - const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; - this.logger.error(errorMessage); - reject(new Error(errorMessage)); - }) - .run(); - }); - } - private async getVideoMetadata(filepath: string): Promise> { - this.logger.log(`Getting video metadata for file: ${filepath}`); - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filepath, (err, metadata) => { - if (err) { - this.logger.error(`Error getting video metadata: ${err.message}`); - reject(err); - return; - } - const videoStream = metadata.streams.find(stream => stream.codec_type === 'video'); - const audioStream = metadata.streams.find(stream => stream.codec_type === 'audio'); - const videoMetadata: Partial = { - width: videoStream?.width || 0, - height: videoStream?.height || 0, - duration: metadata.format.duration || 0, - videoCodec: videoStream?.codec_name || '', - audioCodec: audioStream?.codec_name || '' - }; - this.logger.log(`Extracted video metadata: ${JSON.stringify(videoMetadata)}`); - resolve(videoMetadata); - }); - }); - } -} \ No newline at end of file + const videoStream = metadata.streams.find( + (stream) => stream.codec_type === 'video', + ); + const audioStream = metadata.streams.find( + (stream) => stream.codec_type === 'audio', + ); + const videoMetadata: Partial = { + width: videoStream?.width || 0, + height: videoStream?.height || 0, + duration: metadata.format.duration || 0, + videoCodec: videoStream?.codec_name || '', + audioCodec: audioStream?.codec_name || '', + }; + this.logger.log( + `Extracted video metadata: ${JSON.stringify(videoMetadata)}`, + ); + resolve(videoMetadata); + }); + }); + } +} diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts index cbcd25b..31f3072 100755 --- a/apps/server/src/trpc/trpc.service.ts +++ b/apps/server/src/trpc/trpc.service.ts @@ -8,41 +8,44 @@ import { UserProfileService } from '@server/auth/utils'; type Context = Awaited>; @Injectable() export class TrpcService { - private readonly logger = new Logger(TrpcService.name); + private readonly logger = new Logger(TrpcService.name); - async createExpressContext(opts: trpcExpress.CreateExpressContextOptions): Promise<{ staff: UserProfile | undefined }> { - const token = opts.req.headers.authorization?.split(' ')[1]; - return await UserProfileService.instance.getUserProfileByToken(token); + async createExpressContext( + opts: trpcExpress.CreateExpressContextOptions, + ): Promise<{ staff: UserProfile | undefined }> { + const token = opts.req.headers.authorization?.split(' ')[1]; + return await UserProfileService.instance.getUserProfileByToken(token); + } + async createWSSContext( + opts: CreateWSSContextFnOptions, + ): Promise<{ staff: UserProfile | undefined }> { + const token = opts.info.connectionParams?.token; + return await UserProfileService.instance.getUserProfileByToken(token); + } + trpc = initTRPC.context().create({ + transformer: superjson, + errorFormatter: ({ error, shape }) => { + if (error.code !== 'UNAUTHORIZED') { + this.logger.error(error.message, error.stack); + } + return shape; + }, + }); + + procedure = this.trpc.procedure; + router = this.trpc.router; + mergeRouters = this.trpc.mergeRouters; + + // Define a protected procedure that ensures the user is authenticated + protectProcedure = this.procedure.use(async ({ ctx, next }) => { + if (!ctx?.staff) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' }); } - async createWSSContext(opts: CreateWSSContextFnOptions): Promise<{ staff: UserProfile | undefined }> { - const token = opts.info.connectionParams?.token; - return await UserProfileService.instance.getUserProfileByToken(token); - } - trpc = initTRPC.context().create({ - transformer: superjson, - errorFormatter: ({ error, shape }) => { - if (error.code !== 'UNAUTHORIZED') { - this.logger.error(error.message, error.stack); - } - return shape; - } - }); - - procedure = this.trpc.procedure; - router = this.trpc.router; - mergeRouters = this.trpc.mergeRouters; - - // Define a protected procedure that ensures the user is authenticated - protectProcedure = this.procedure.use(async ({ ctx, next }) => { - if (!ctx?.staff) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: "未授权请求" }); - } - return next({ - ctx: { - // User value is confirmed to be non-null at this point - staff: ctx.staff, - }, - }); - + return next({ + ctx: { + // User value is confirmed to be non-null at this point + staff: ctx.staff, + }, }); + }); } diff --git a/apps/web/src/app/main/course/editor/page.tsx b/apps/web/src/app/main/course/editor/page.tsx index 6603f60..8038ffa 100644 --- a/apps/web/src/app/main/course/editor/page.tsx +++ b/apps/web/src/app/main/course/editor/page.tsx @@ -2,7 +2,7 @@ import CourseEditor from "@web/src/components/models/course/manage/CourseEditor" import { useParams } from "react-router-dom"; export function CourseEditorPage() { - const { id } = useParams(); - console.log('Course ID:', id); - return -} \ No newline at end of file + const { id, part } = useParams(); + console.log("Course ID:", id); + return ; +} diff --git a/apps/web/src/components/models/course/list/course-list.tsx b/apps/web/src/components/models/course/list/course-list.tsx index 2da2963..9199f07 100644 --- a/apps/web/src/components/models/course/list/course-list.tsx +++ b/apps/web/src/components/models/course/list/course-list.tsx @@ -3,60 +3,58 @@ import { motion } from "framer-motion"; import { Course, CourseDto } from "@nice/common"; import { EmptyState } from "@web/src/components/presentation/space/Empty"; import { Pagination } from "@web/src/components/presentation/element/Pagination"; - +import React from "react"; interface CourseListProps { - courses?: CourseDto[]; - renderItem: (course: CourseDto) => React.ReactNode; - emptyComponent?: React.ReactNode; - // 新增分页相关属性 - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; + courses?: CourseDto[]; + renderItem: (course: CourseDto) => React.ReactNode; + emptyComponent?: React.ReactNode; + // 新增分页相关属性 + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; } const container = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.05, - duration: 0.3 - }, - }, + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + duration: 0.3, + }, + }, }; export const CourseList = ({ - courses, - renderItem, - emptyComponent: EmptyComponent, - currentPage, - totalPages, - onPageChange, + courses, + renderItem, + emptyComponent: EmptyComponent, + currentPage, + totalPages, + onPageChange, }: CourseListProps) => { - if (!courses || courses.length === 0) { - return EmptyComponent || ; - } + if (!courses || courses.length === 0) { + return EmptyComponent || ; + } + return ( +
+ + {courses.map((course) => ( + + {renderItem(course)} + + ))} + - return ( -
- - {courses.map((course) => ( - - {renderItem(course)} - - ))} - - - -
- ); -}; \ No newline at end of file + +
+ ); +}; diff --git a/apps/web/src/components/models/course/manage/CourseEditor.tsx b/apps/web/src/components/models/course/manage/CourseEditor.tsx index bbb5152..af8fc7c 100644 --- a/apps/web/src/components/models/course/manage/CourseEditor.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditor.tsx @@ -1,12 +1,20 @@ import { CourseBasicForm } from "./CourseForms/CourseBasicForm"; import { CourseFormProvider } from "./CourseEditorContext"; import CourseEditorLayout from "./CourseEditorLayout"; +import { CourseTargetForm } from "./CourseForms/CourseTargetForm"; +import CourseForm from "./CourseForms/CourseForm"; -export default function CourseEditor({ id }: { id?: string }) { +export default function CourseEditor({ + id, + part, +}: { + id?: string; + part?: string; +}) { return ( - + - + ); diff --git a/apps/web/src/components/models/course/manage/CourseEditorContext.tsx b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx index 5e24160..42f9c6f 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx @@ -1,108 +1,119 @@ -import { createContext, useContext, ReactNode, useEffect } from 'react'; -import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { CourseDto, CourseLevel, CourseStatus } from '@nice/common'; -import { api, useCourse } from '@nice/client'; -import toast from 'react-hot-toast'; -import { useNavigate } from 'react-router-dom'; +import { createContext, useContext, ReactNode, useEffect } from "react"; +import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CourseDto, CourseLevel, CourseStatus } from "@nice/common"; +import { api, useCourse } from "@nice/client"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; // 定义课程表单验证 Schema const courseSchema = z.object({ - title: z.string().min(1, '课程标题不能为空'), - subTitle: z.string().nullish(), - description: z.string().nullish(), - thumbnail: z.string().url().nullish(), - level: z.nativeEnum(CourseLevel), - requirements: z.array(z.string()).nullish(), - objectives: z.array(z.string()).nullish(), - skills: z.array(z.string()).nullish(), - audiences: z.array(z.string()).nullish(), - status: z.nativeEnum(CourseStatus), + title: z.string().min(1, "课程标题不能为空"), + subTitle: z.string().nullish(), + description: z.string().nullish(), + thumbnail: z.string().url().nullish(), + level: z.nativeEnum(CourseLevel), + requirements: z.array(z.string()).nullish(), + objectives: z.array(z.string()).nullish(), + skills: z.array(z.string()).nullish(), + audiences: z.array(z.string()).nullish(), + status: z.nativeEnum(CourseStatus), }); export type CourseFormData = z.infer; interface CourseEditorContextType { - onSubmit: SubmitHandler; - editId?: string; // 添加 editId - course?: CourseDto + onSubmit: SubmitHandler; + editId?: string; // 添加 editId + part?: string; + course?: CourseDto; } interface CourseFormProviderProps { - children: ReactNode; - editId?: string; // 添加 editId 参数 + children: ReactNode; + editId?: string; // 添加 editId 参数 + part?: string; } const CourseEditorContext = createContext(null); -export function CourseFormProvider({ children, editId }: CourseFormProviderProps) { - const { create, update } = useCourse() - const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) }) - const methods = useForm({ - resolver: zodResolver(courseSchema), - defaultValues: { - status: CourseStatus.DRAFT, - level: CourseLevel.BEGINNER, - requirements: [], - objectives: [], - skills: [], - audiences: [], - }, - }); - const navigate = useNavigate() - useEffect(() => { - if (course) { - // 只选择表单需要的字段 - const formData = { - title: course.title, - subTitle: course.subTitle, - description: course.description, - thumbnail: course.thumbnail, - level: course.level, - requirements: course.requirements, - objectives: course.objectives, - skills: course.skills, - audiences: course.audiences, - status: course.status, - }; - methods.reset(formData as any); - } - }, [course, methods]); - const onSubmit: SubmitHandler = async (data: CourseFormData) => { - try { - if (editId) { - await update.mutateAsync({ - where: { id: editId }, - data: { - ...data - } - }) - toast.success('课程更新成功!'); - } else { - const result = await create.mutateAsync({ - data: { - ...data - } - }) - console.log(`/course/${result.id}/manage`) - navigate(`/course/${result.id}/manage`, { replace: true }) - toast.success('课程创建成功!'); - } +export function CourseFormProvider({ + children, + editId, + part, +}: CourseFormProviderProps) { + const { create, update } = useCourse(); + const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery( + { where: { id: editId } }, + { enabled: Boolean(editId) } + ); + const methods = useForm({ + resolver: zodResolver(courseSchema), + defaultValues: { + status: CourseStatus.DRAFT, + level: CourseLevel.BEGINNER, + requirements: [], + objectives: [], + skills: [], + audiences: [], + }, + }); + const navigate = useNavigate(); + useEffect(() => { + if (course) { + // 只选择表单需要的字段 + const formData = { + title: course.title, + subTitle: course.subTitle, + description: course.description, + thumbnail: course.thumbnail, + level: course.level, + requirements: course.requirements, + objectives: course.objectives, + skills: course.skills, + audiences: course.audiences, + status: course.status, + }; + methods.reset(formData as any); + } + }, [course, methods]); + const onSubmit: SubmitHandler = async ( + data: CourseFormData + ) => { + try { + if (editId) { + await update.mutateAsync({ + where: { id: editId }, + data: { + ...data, + }, + }); + toast.success("课程更新成功!"); + } else { + const result = await create.mutateAsync({ + data: { + ...data, + }, + }); + console.log(`/course/${result.id}/manage`); + navigate(`/course/${result.id}/manage`, { replace: true }); + toast.success("课程创建成功!"); + } + } catch (error) { + console.error("Error submitting form:", error); + toast.error("操作失败,请重试!"); + } + }; - } catch (error) { - console.error('Error submitting form:', error); - toast.error('操作失败,请重试!'); - } - }; - - return ( - - - {children} - - - ); + return ( + + {children} + + ); } export const useCourseEditor = () => { - const context = useContext(CourseEditorContext); - if (!context) { - throw new Error('useCourseEditor must be used within CourseFormProvider'); - } - return context; -}; \ No newline at end of file + const context = useContext(CourseEditorContext); + if (!context) { + throw new Error( + "useCourseEditor must be used within CourseFormProvider" + ); + } + return context; +}; diff --git a/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx index d3e93b9..9184645 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx @@ -13,6 +13,7 @@ const courseStatusVariant: Record = { }; export default function CourseEditorHeader() { const navigate = useNavigate(); + const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext() const { onSubmit, course } = useCourseEditor() return ( diff --git a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx index 08ae53e..f4ffb73 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx @@ -12,6 +12,7 @@ interface CourseEditorLayoutProps { export default function CourseEditorLayout({ children, }: CourseEditorLayoutProps) { + const [isHovered, setIsHovered] = useState(false); const [selectedSection, setSelectedSection] = useState(0); const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx index f9042ac..937d065 100644 --- a/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx @@ -7,6 +7,7 @@ import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { convertToOptions } from "@nice/client"; import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; +import { useEffect } from "react"; export function CourseBasicForm() { const { @@ -15,7 +16,9 @@ export function CourseBasicForm() { watch, handleSubmit, } = useFormContext(); - + useEffect(() => { + console.log(watch("audiences")); + }, [watch("audiences")]); return (
- - (); + useEffect(() => { + console.log(watch("audiences")); + }, [watch("audiences")]); + return ( + + + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx new file mode 100644 index 0000000..ecd74cf --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { useCourseEditor } from "../CourseEditorContext"; +import { CoursePart } from "../enum"; +import { CourseBasicForm } from "./CourseBasicForm"; +import { CourseTargetForm } from "./CourseTargetForm"; +import { CourseContentForm } from "./CourseContentForm"; + +export default function CourseForm() { + const { part } = useCourseEditor(); + if (part === CoursePart.OVERVIEW) { + return ; + } + if (part === CoursePart.TARGET) { + return ; + } + if (part === CoursePart.CONTENT) { + return ; + } + if (part === CoursePart.SETTING) { + return <>; + } + return ; +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx similarity index 88% rename from apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx rename to apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx index 557aa55..0dd1902 100644 --- a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx @@ -6,8 +6,10 @@ import { FormInput } from "@web/src/components/presentation/form/FormInput"; import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; +import { useEffect } from "react"; -export function CourseBasicForm() { +export function CourseContentForm() { const { register, formState: { errors }, diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx new file mode 100644 index 0000000..50a4d82 --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx @@ -0,0 +1,46 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData, useCourseEditor } from "../CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/presentation/form/FormInput"; +import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; +import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; +import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; + +export function CourseTargetForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + + {/* */} +
+ ); +} diff --git a/apps/web/src/components/models/course/manage/enum.ts b/apps/web/src/components/models/course/manage/enum.ts new file mode 100644 index 0000000..81a97a6 --- /dev/null +++ b/apps/web/src/components/models/course/manage/enum.ts @@ -0,0 +1,6 @@ +export enum CoursePart { + OVERVIEW = "overview", + TARGET = "target", + CONTENT = "content", + SETTING = "settings", +} diff --git a/apps/web/src/components/models/course/manage/navItems.tsx b/apps/web/src/components/models/course/manage/navItems.tsx index b2342ae..b5eb763 100644 --- a/apps/web/src/components/models/course/manage/navItems.tsx +++ b/apps/web/src/components/models/course/manage/navItems.tsx @@ -5,26 +5,54 @@ import { VideoCameraIcon, } from "@heroicons/react/24/outline"; import { NavItem } from "@nice/client"; +import { CoursePart } from "./enum"; +export const DEFAULT_NAV_ITEMS = ( + courseId?: string +): (NavItem & { isCompleted?: boolean })[] => { + const basePath = courseId ? `/course/${courseId}` : "/course"; -export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ - { - label: "课程概述", - icon: , - path: "/manage/overview", - }, - { - label: "目标学员", - icon: , - path: "/manage/target", - }, - { - label: "课程内容", - icon: , - path: "/manage/content", - }, - { - label: "课程设置", - icon: , - path: "/manage/settings", - }, -]; + return [ + { + label: "课程概述", + icon: , + path: `${basePath}/manage/${CoursePart.OVERVIEW}`, + }, + { + label: "目标学员", + icon: , + path: `${basePath}/manage/${CoursePart.TARGET}`, + }, + { + label: "课程内容", + icon: , + path: `${basePath}/manage/${CoursePart.CONTENT}`, + }, + { + label: "课程设置", + icon: , + path: `${basePath}/manage/${CoursePart.SETTING}`, + }, + ]; +}; +// export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ +// { +// label: "课程概述", +// icon: , +// path: `/course/${}/manage/${CoursePart.OVERVIEW}`, +// }, +// { +// label: "目标学员", +// icon: , +// path: `/manage/${CoursePart.TARGET}`, +// }, +// { +// label: "课程内容", +// icon: , +// path: `/manage/${CoursePart.CONTENT}`, +// }, +// { +// label: "课程设置", +// icon: , +// path: `/manage/${CoursePart.SETTING}`, +// }, +// ]; diff --git a/apps/web/src/components/presentation/form/FormDynamicInputs.tsx b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx index ec64367..6f2a9d2 100644 --- a/apps/web/src/components/presentation/form/FormDynamicInputs.tsx +++ b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"; import React, { useState } from "react"; import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; import FormError from "./FormError"; +import { TrashIcon } from "@heroicons/react/24/solid"; export interface DynamicFormInputProps extends Omit< @@ -11,6 +12,8 @@ export interface DynamicFormInputProps "type" > { name: string; + addTitle?: string; + subTitle?: string; label: string; type?: | "text" @@ -29,7 +32,9 @@ export interface DynamicFormInputProps export function FormDynamicInputs({ name, + addTitle, label, + subTitle, type = "text", rows = 4, className, @@ -49,7 +54,14 @@ export function FormDynamicInputs({ control, name, }); - + // 添加 onChange 处理函数 + const handleInputChange = (index: number, value: string) => { + setValue(`${name}.${index}`, value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }; const handleBlur = async (index: number) => { setFocusedIndexes(focusedIndexes.filter((i) => i !== index)); await trigger(`${name}.${index}`); @@ -65,12 +77,16 @@ export function FormDynamicInputs({ `; const InputElement = type === "textarea" ? "textarea" : "input"; - return (
+ {subTitle && ( + + )} {fields.map((field, index) => ( @@ -83,7 +99,13 @@ export function FormDynamicInputs({ className="group relative">
+ handleInputChange( + index, + e.target.value + ), + })} type={type !== "textarea" ? type : undefined} rows={type === "textarea" ? rows : undefined} {...restProps} @@ -97,40 +119,20 @@ export function FormDynamicInputs({ className={inputClasses} /> -
- {values[index] && - focusedIndexes.includes(index) && ( - - )} + {/* 修改这部分,将删除按钮放在 input 内部右侧 */} +
{values[index] && !fieldErrors?.[index] && ( )} + {fields.length > 1 && ( + + )}
- - {index > 0 && ( - remove(index)} - className="absolute -right-2 -top-2 p-1 bg-red-500 rounded-full - text-white shadow-sm opacity-0 group-hover:opacity-100 - transition-opacity duration-200"> - - - )}
{fieldErrors?.[index]?.message && ( @@ -147,7 +149,7 @@ export function FormDynamicInputs({ className="flex items-center gap-1 text-blue-500 hover:text-blue-600 transition-colors px-4 py-2 rounded-lg hover:bg-blue-50"> - 添加新{label} + 添加新{addTitle || label}
); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a2ef037..97bbe82 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -84,22 +84,8 @@ export const routes: CustomRouteObject[] = [ path: "course", children: [ { - path: ":id?/manage", // 使用 ? 表示 id 参数是可选的 + path: ":id?/manage/:part?", // 使用 ? 表示 id 参数是可选的 element: , - children: [ - { - index: true, // This will make :id?/manage the default route - element: , - }, - { - path: "overview", - element: , // You might want to create a specific overview component - }, - { - path: "target", - element: , // Create a specific target page component - }, - ], }, { path: ":id?/detail", // 使用 ? 表示 id 参数是可选的