diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js index 259de13..259975e 100755 --- a/apps/server/.eslintrc.js +++ b/apps/server/.eslintrc.js @@ -21,5 +21,20 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + // 允许使用 any 类型 + '@typescript-eslint/no-explicit-any': 'off', + + // 允许声明但未使用的变量 + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', // 检查所有变量 + args: 'none', // 不检查函数参数 + ignoreRestSiblings: true, + }, + ], + + // 禁止使用未声明的变量 + 'no-undef': 'error', }, }; diff --git a/apps/server/src/models/resource/resource.service.ts b/apps/server/src/models/resource/resource.service.ts index 6f0ac62..3a35a7e 100644 --- a/apps/server/src/models/resource/resource.service.ts +++ b/apps/server/src/models/resource/resource.service.ts @@ -19,11 +19,10 @@ export class ResourceService extends BaseService { params?: { staff?: UserProfile }, ): Promise { if (params?.staff) { - args.data.ownerId = params?.staff?.id + args.data.ownerId = params?.staff?.id; } return super.create(args); } - async softDeleteByFileId(fileId: string) { return this.update({ where: { @@ -34,4 +33,4 @@ export class ResourceService extends BaseService { }, }); } -} \ No newline at end of file +} diff --git a/apps/server/src/tasks/init/init.service.ts b/apps/server/src/tasks/init/init.service.ts index 1b2a420..575d686 100755 --- a/apps/server/src/tasks/init/init.service.ts +++ b/apps/server/src/tasks/init/init.service.ts @@ -1,5 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; -import { db, InitAppConfigs, InitRoles, InitTaxonomies, ObjectType } from "@nice/common"; +import { + db, + InitAppConfigs, + InitRoles, + InitTaxonomies, + ObjectType, +} from '@nice/common'; import { AuthService } from '@server/auth/auth.service'; import { MinioService } from '@server/utils/minio/minio.service'; import { AppConfigService } from '@server/models/app-config/app-config.service'; @@ -7,142 +13,144 @@ import { GenDevService } from './gendev.service'; @Injectable() export class InitService { - private readonly logger = new Logger(InitService.name); - constructor( - private readonly appConfigService: AppConfigService, - private readonly minioService: MinioService, - private readonly authService: AuthService, - private readonly genDevService: GenDevService - ) { } - private async createRoles() { - this.logger.log('Checking existing system roles'); - for (const role of InitRoles) { - const existingRole = await db.role.findUnique({ - where: { name: role.name }, - }); - if (!existingRole) { - this.logger.log(`Creating role: ${role.name}`); - await db.role.create({ - data: { ...role, system: true }, - }); - } else { - this.logger.log(`Role already exists: ${role.name}`); - } - } - } - private async createOrUpdateTaxonomy() { - this.logger.log('Checking existing taxonomies'); - - const existingTaxonomies = await db.taxonomy.findMany(); - const existingTaxonomyMap = new Map(existingTaxonomies.map(taxonomy => [taxonomy.name, taxonomy])); - - for (const [index, taxonomy] of InitTaxonomies.entries()) { - const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name); - - if (!existingTaxonomy) { - // Create new taxonomy - await db.taxonomy.create({ - data: { - ...taxonomy, - order: index, - }, - }); - this.logger.log(`Created new taxonomy: ${taxonomy.name}`); - } else { - // Check for differences and update if necessary - const differences = Object.keys(taxonomy).filter(key => taxonomy[key] !== existingTaxonomy[key]); - - if (differences.length > 0) { - await db.taxonomy.update({ - where: { id: existingTaxonomy.id }, - data: { - ...taxonomy, - order: index, - }, - }); - this.logger.log(`Updated taxonomy: ${taxonomy.name}`); - } else { - this.logger.log(`No changes for taxonomy: ${taxonomy.name}`); - } - } - } - } - private async createRoot() { - this.logger.log('Checking for root account'); - - const rootAccountExists = await db.staff.findFirst({ - where: { - OR: [ - { - phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000', - }, - { - username: 'root', - }, - ], - }, + private readonly logger = new Logger(InitService.name); + constructor( + private readonly appConfigService: AppConfigService, + private readonly minioService: MinioService, + private readonly authService: AuthService, + private readonly genDevService: GenDevService, + ) {} + private async createRoles() { + this.logger.log('Checking existing system roles'); + for (const role of InitRoles) { + const existingRole = await db.role.findUnique({ + where: { name: role.name }, + }); + if (!existingRole) { + this.logger.log(`Creating role: ${role.name}`); + await db.role.create({ + data: { ...role, system: true }, }); + } else { + this.logger.log(`Role already exists: ${role.name}`); + } + } + } + private async createOrUpdateTaxonomy() { + this.logger.log('Checking existing taxonomies'); - if (!rootAccountExists) { - this.logger.log('Creating root account'); - const rootStaff = await this.authService.signUp({ - username: 'root', - password: 'root', - }); - const rootRole = await db.role.findUnique({ - where: { name: '根管理员' }, - }); + const existingTaxonomies = await db.taxonomy.findMany(); + const existingTaxonomyMap = new Map( + existingTaxonomies.map((taxonomy) => [taxonomy.name, taxonomy]), + ); - if (rootRole) { - this.logger.log('Assigning root role to root account'); - await db.roleMap.create({ - data: { - objectType: ObjectType.STAFF, - objectId: rootStaff.id, - roleId: rootRole.id, - }, - }); - } else { - this.logger.error('Root role does not exist'); - } + for (const [index, taxonomy] of InitTaxonomies.entries()) { + const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name); + + if (!existingTaxonomy) { + // Create new taxonomy + await db.taxonomy.create({ + data: { + ...taxonomy, + order: index, + }, + }); + this.logger.log(`Created new taxonomy: ${taxonomy.name}`); + } else { + // Check for differences and update if necessary + const differences = Object.keys(taxonomy).filter( + (key) => taxonomy[key] !== existingTaxonomy[key], + ); + + if (differences.length > 0) { + await db.taxonomy.update({ + where: { id: existingTaxonomy.id }, + data: { + ...taxonomy, + order: index, + }, + }); + this.logger.log(`Updated taxonomy: ${taxonomy.name}`); } else { - this.logger.log('Root account already exists'); + this.logger.log(`No changes for taxonomy: ${taxonomy.name}`); } + } } - private async createBucket() { - await this.minioService.createBucket('app') - } - private async initAppConfigs() { - const existingConfigs = await db.appConfig.findMany(); - const existingConfigSlugs = existingConfigs.map((config) => config.slug); - for (const [index, config] of InitAppConfigs.entries()) { - if (!existingConfigSlugs.includes(config.slug)) { - this.logger.log(`create Option Page ${config.title}`); - await this.appConfigService.create({ data: config }); - } else { - this.logger.log(`AppConfig already exists: ${config.title}`); - } - } - } - async init() { - this.logger.log('Initializing system roles'); - await this.createRoles(); - this.logger.log('Initializing root account'); - await this.createRoot(); - this.logger.log('Initializing taxonomies'); - await this.createOrUpdateTaxonomy() - this.logger.log('Initialize minio') - await this.createBucket() - this.logger.log('Initializing appConfigs'); - await this.initAppConfigs(); - if (process.env.NODE_ENV === 'development') { - try { - await this.genDevService.genDataEvent(); - } catch (err: any) { - this.logger.error(err.message); - } - } + } + private async createRoot() { + this.logger.log('Checking for root account'); + const rootAccountExists = await db.staff.findFirst({ + where: { + OR: [ + { + phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000', + }, + { + username: 'root', + }, + ], + }, + }); + if (!rootAccountExists) { + this.logger.log('Creating root account'); + const rootStaff = await this.authService.signUp({ + username: 'root', + password: 'root', + }); + const rootRole = await db.role.findUnique({ + where: { name: '根管理员' }, + }); + + if (rootRole) { + this.logger.log('Assigning root role to root account'); + await db.roleMap.create({ + data: { + objectType: ObjectType.STAFF, + objectId: rootStaff.id, + roleId: rootRole.id, + }, + }); + } else { + this.logger.error('Root role does not exist'); + } + } else { + this.logger.log('Root account already exists'); } + } + private async createBucket() { + await this.minioService.createBucket('app'); + } + private async initAppConfigs() { + const existingConfigs = await db.appConfig.findMany(); + const existingConfigSlugs = existingConfigs.map((config) => config.slug); + for (const [index, config] of InitAppConfigs.entries()) { + if (!existingConfigSlugs.includes(config.slug)) { + this.logger.log(`create Option Page ${config.title}`); + await this.appConfigService.create({ data: config }); + } else { + this.logger.log(`AppConfig already exists: ${config.title}`); + } + } + } + async init() { + this.logger.log('Initializing system roles'); + await this.createRoles(); + this.logger.log('Initializing root account'); + await this.createRoot(); + this.logger.log('Initializing taxonomies'); + await this.createOrUpdateTaxonomy(); + this.logger.log('Initialize minio'); + await this.createBucket(); + this.logger.log('Initializing appConfigs'); + await this.initAppConfigs(); + if (process.env.NODE_ENV === 'development') { + try { + await this.genDevService.genDataEvent(); + } catch (err: any) { + this.logger.error(err.message); + } + } + } } diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 092408a..592790b 100755 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -1,28 +1,43 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // 允许使用 any 类型 + "@typescript-eslint/no-explicit-any": "off", + + // 允许声明但未使用的变量 + "@typescript-eslint/no-unused-vars": [ + "warn", + { + vars: "all", // 检查所有变量 + args: "none", // 不检查函数参数 + ignoreRestSiblings: true, + }, + ], + + // 禁止使用未声明的变量 + "no-undef": "error", + }, + } +); diff --git a/apps/web/package.json b/apps/web/package.json index 53aaa76..9f4b82a 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,8 +46,10 @@ "clsx": "^2.1.1", "dayjs": "^1.11.12", "framer-motion": "^11.15.0", + "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", @@ -57,9 +59,9 @@ "react-router-dom": "^6.24.1", "superjson": "^2.2.1", "tailwind-merge": "^2.6.0", + "uuid": "^10.0.0", "yjs": "^13.6.20", - "zod": "^3.23.8", - "uuid": "^10.0.0" + "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/apps/web/src/app/main/course/detail/page.tsx b/apps/web/src/app/main/course/detail/page.tsx new file mode 100644 index 0000000..d4b385a --- /dev/null +++ b/apps/web/src/app/main/course/detail/page.tsx @@ -0,0 +1,9 @@ +import CourseDetail from "@web/src/components/models/course/detail/CourseDetail"; +import CourseEditor from "@web/src/components/models/course/manage/CourseEditor"; +import { useParams } from "react-router-dom"; + +export function CourseDetailPage() { + const { id } = useParams(); + console.log("Course ID:", id); + return ; +} diff --git a/apps/web/src/app/main/course/page.tsx b/apps/web/src/app/main/course/page.tsx index 3ed549f..9f5dd6d 100644 --- a/apps/web/src/app/main/course/page.tsx +++ b/apps/web/src/app/main/course/page.tsx @@ -1,34 +1,34 @@ -import { CourseCard } from "@web/src/components/models/course/card/CourseCard" -import { CourseDetail } from "@web/src/components/models/course/detail/course-detail" -import { CourseSyllabus } from "@web/src/components/models/course/detail/course-syllabus" +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 */ - ] + // 假设这些数据从API获取 + const course: any = { + /* course data */ + }; + const sections: any = [ + /* sections data */ + ]; - return ( -
-
- {/* 左侧课程详情 */} -
- -
- {/* 右侧课程大纲 */} -
- - { - console.log('Clicked lecture:', lectureId) - }} - /> -
-
-
- ) -} \ No newline at end of file + 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 bc0292e..bd27993 100644 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ b/apps/web/src/app/main/courses/instructor/page.tsx @@ -8,48 +8,59 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "@web/src/providers/auth-provider"; export default function InstructorCoursesPage() { - const navigate = useNavigate() - const [currentPage, setCurrentPage] = useState(1); - const { user } = useAuth() + const navigate = useNavigate(); + const [currentPage, setCurrentPage] = useState(1); + const { user } = useAuth(); - const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({ - page: currentPage, - pageSize: 8, - where: { - instructors: { - some: { - instructorId: user?.id - } - } - } - }); + const { data: paginationRes, refetch } = + api.course.findManyWithPagination.useQuery({ + page: currentPage, + pageSize: 8, + where: { + instructors: { + some: { + instructorId: user?.id, + }, + }, + }, + }); - const handlePageChange = (page: number) => { - setCurrentPage(page); - refetch() - }; + const handlePageChange = (page: number) => { + setCurrentPage(page); + refetch(); + }; - return ( -
-
-
-

我教授的课程

- -
- } - /> -
-
- ); -} \ No newline at end of file + return ( +
+
+
+

+ 我教授的课程 +

+ +
+ ( + { + navigate(`/course/${course.id}/detail`, { + replace: true, + }); + }} + course={course} + /> + )} + /> +
+
+ ); +} diff --git a/apps/web/src/components/models/course/card/CourseCard.tsx b/apps/web/src/components/models/course/card/CourseCard.tsx index 560aabb..f53f827 100644 --- a/apps/web/src/components/models/course/card/CourseCard.tsx +++ b/apps/web/src/components/models/course/card/CourseCard.tsx @@ -1,30 +1,32 @@ -import { CourseDto } from '@nice/common'; -import { Card } from '@web/src/components/presentation/container/Card'; -import { CourseHeader } from './CourseHeader'; -import { CourseStats } from './CourseStats'; +import { CourseDto } from "@nice/common"; +import { Card } from "@web/src/components/presentation/container/Card"; +import { CourseHeader } from "./CourseHeader"; +import { CourseStats } from "./CourseStats"; +import { Popover } from "@web/src/components/presentation/popover"; +import { useState } from "react"; interface CourseCardProps { - course: CourseDto; - onClick?: () => void; + course: CourseDto; + onClick?: () => void; } export const CourseCard = ({ course, onClick }: CourseCardProps) => { - return ( - - - - - ); -}; \ No newline at end of file + return ( + + + + + ); +}; diff --git a/apps/web/src/components/models/course/card/CourseHeader.tsx b/apps/web/src/components/models/course/card/CourseHeader.tsx index b6fe3cf..95780a1 100644 --- a/apps/web/src/components/models/course/card/CourseHeader.tsx +++ b/apps/web/src/components/models/course/card/CourseHeader.tsx @@ -1,59 +1,58 @@ -import { CalendarIcon, UserGroupIcon, AcademicCapIcon } from '@heroicons/react/24/outline'; +import { + CalendarIcon, + UserGroupIcon, + AcademicCapIcon, +} from "@heroicons/react/24/outline"; +import { CourseLevelLabel } from "@nice/common"; interface CourseHeaderProps { - title: string; - subTitle?: string; - thumbnail?: string; - level?: string; - numberOfStudents?: number; - publishedAt?: Date; + title: string; + subTitle?: string; + thumbnail?: string; + level?: string; + numberOfStudents?: number; + publishedAt?: Date; } export const CourseHeader = ({ - title, - subTitle, - thumbnail, - level, - numberOfStudents, - publishedAt, + title, + subTitle, + thumbnail, + level, + numberOfStudents, + publishedAt, }: CourseHeaderProps) => { - return ( -
- {thumbnail && ( -
- {title} -
- )} -
-

{title}

- {subTitle && ( -

{subTitle}

- )} -
- {level && ( -
- - {level} -
- )} - {numberOfStudents !== undefined && ( -
- - {numberOfStudents} students -
- )} - {publishedAt && ( -
- - {publishedAt.toLocaleDateString()} -
- )} -
-
-
- ); -}; \ No newline at end of file + return ( +
+ {thumbnail && ( +
+ {title} +
+ )} +
+

{title}

+ {subTitle &&

{subTitle}

} +
+ {level && ( +
+ + {CourseLevelLabel[level]} +
+ )} + {numberOfStudents !== undefined && ( +
+ + {numberOfStudents} 人学习中 +
+ )} + {publishedAt && ( +
+ + {publishedAt.toLocaleDateString()} +
+ )} +
+
+
+ ); +}; diff --git a/apps/web/src/components/models/course/card/CourseStats.tsx b/apps/web/src/components/models/course/card/CourseStats.tsx index 71c416b..0ff3906 100644 --- a/apps/web/src/components/models/course/card/CourseStats.tsx +++ b/apps/web/src/components/models/course/card/CourseStats.tsx @@ -23,7 +23,7 @@ export const CourseStats = ({ {averageRating.toFixed(1)}
- {numberOfReviews} reviews + {numberOfReviews} 观看量
@@ -36,7 +36,7 @@ export const CourseStats = ({ {completionRate}%
- Completion + 完成率
@@ -49,7 +49,7 @@ export const CourseStats = ({ {Math.floor(totalDuration / 60)}h {totalDuration % 60}m
- Duration + 总时长
diff --git a/apps/web/src/components/models/course/detail/CourseDetail.tsx b/apps/web/src/components/models/course/detail/CourseDetail.tsx new file mode 100644 index 0000000..402fbd7 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetail.tsx @@ -0,0 +1,17 @@ +import { CourseDetailProvider } from "./CourseDetailContext"; +import CourseDetailLayout from "./CourseDetailLayout"; + +export default function CourseDetail({ id }: { id?: string }) { + const iframeStyle = { + width: "50%", + height: "100vh", + border: "none", + }; + return ( + <> + + + + + ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailContent.tsx b/apps/web/src/components/models/course/detail/CourseDetailContent.tsx new file mode 100644 index 0000000..f533b3f --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailContent.tsx @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..ffa25ff --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -0,0 +1,50 @@ +import { api, useCourse } from "@nice/client"; +import { courseDetailSelect, CourseDto } from "@nice/common"; +import React, { createContext, ReactNode, useState } from "react"; +import { string } from "zod"; + +interface CourseDetailContextType { + editId?: string; // 添加 editId + course?: CourseDto; + selectedLectureId?: string | undefined; + setSelectedLectureId?: React.Dispatch>; + isLoading?: boolean; +} +interface CourseFormProviderProps { + children: ReactNode; + editId?: string; // 添加 editId 参数 +} +export const CourseDetailContext = + createContext(null); +export function CourseDetailProvider({ + children, + editId, +}: CourseFormProviderProps) { + const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = + api.course.findFirst.useQuery( + { + where: { id: editId }, + include: { + sections: { include: { lectures: true } }, + enrollments: true, + }, + }, + { enabled: Boolean(editId) } + ); + const [selectedLectureId, setSelectedLectureId] = useState< + string | undefined + >(undefined); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx new file mode 100644 index 0000000..8e5b7ec --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx @@ -0,0 +1,42 @@ +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"; + +export default function CourseDetailLayout() { + const { course, selectedLectureId, isLoading, setSelectedLectureId } = + useContext(CourseDetailContext); + + const handleLectureClick = (lectureId: string) => { + setSelectedLectureId(lectureId); + }; + const [isSyllabusOpen, setIsSyllabusOpen] = useState(false); + return ( +
+ {/* 主内容区域 */} + + + + + {/* 课程大纲侧边栏 */} + setIsSyllabusOpen(!isSyllabusOpen)} + /> +
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailSkeleton.tsx b/apps/web/src/components/models/course/detail/CourseDetailSkeleton.tsx new file mode 100644 index 0000000..95b1049 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailSkeleton.tsx @@ -0,0 +1,40 @@ +import { + SkeletonItem, + SkeletonSection, +} from "@web/src/components/presentation/Skeleton"; + +export const CourseDetailSkeleton = () => { + return ( +
+ {/* 标题骨架屏 */} +
+ + +
+ + {/* 描述骨架屏 */} + + + {/* 学习目标骨架屏 */} + + + {/* 适合人群骨架屏 */} + + + {/* 技能骨架屏 */} +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ ); +}; +export default CourseDetailSkeleton; diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus.tsx new file mode 100644 index 0000000..51f18cf --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseSyllabus.tsx @@ -0,0 +1,214 @@ +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/CourseVideoPage.tsx b/apps/web/src/components/models/course/detail/CourseVideoPage.tsx new file mode 100644 index 0000000..a083182 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseVideoPage.tsx @@ -0,0 +1,51 @@ +// components/CourseVideoPage.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 { Course } from "@nice/common"; + +interface CourseVideoPageProps { + course: Course; + videoSrc?: string; + videoPoster?: string; + isLoading?: boolean; +} + +export const CourseVideoPage: React.FC = ({ + course, + videoSrc, + videoPoster, + isLoading = false, +}) => { + // 创建滚动动画效果 + const { scrollY } = useScroll(); + const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]); + const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]); + + return ( +
+ {/* 固定的视频区域 */} + +
+ +
+
+ + {/* 课程内容区域 */} + + + +
+ ); +}; +export default CourseVideoPage; diff --git a/apps/web/src/components/models/course/detail/course-detail.tsx b/apps/web/src/components/models/course/detail/course-detail.tsx deleted file mode 100644 index f949573..0000000 --- a/apps/web/src/components/models/course/detail/course-detail.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { CheckCircleIcon } from '@heroicons/react/24/outline' -import { Course } from "@nice/common" -interface CourseDetailProps { - course: Course -} - -export const CourseDetail: React.FC = ({ course }) => { - 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} - - ))} -
-
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/course-syllabus.tsx b/apps/web/src/components/models/course/detail/course-syllabus.tsx deleted file mode 100644 index 18ab849..0000000 --- a/apps/web/src/components/models/course/detail/course-syllabus.tsx +++ /dev/null @@ -1,78 +0,0 @@ - -import { ChevronDownIcon, ClockIcon, PlayCircleIcon } from '@heroicons/react/24/outline' -import { useState } from 'react' -import { Section, SectionDto } from "@nice/common" -interface CourseSyllabusProps { - sections: SectionDto[] - onLectureClick?: (lectureId: string) => void -} - -export const CourseSyllabus: React.FC = ({ - sections, - onLectureClick -}) => { - const [expandedSections, setExpandedSections] = useState([]) - - const toggleSection = (sectionId: string) => { - setExpandedSections(prev => - prev.includes(sectionId) - ? prev.filter(id => id !== sectionId) - : [...prev, sectionId] - ) - } - - return ( -
- {sections.map((section) => ( -
- {/* 章节标题 */} - - - {/* 课时列表 */} - {expandedSections.includes(section.id) && ( -
- {section.lectures.map((lecture) => ( - - ))} -
- )} -
- ))} -
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/presentation/Skeleton.tsx b/apps/web/src/components/presentation/Skeleton.tsx new file mode 100644 index 0000000..19c8234 --- /dev/null +++ b/apps/web/src/components/presentation/Skeleton.tsx @@ -0,0 +1,43 @@ +// components/presentation/Skeleton.tsx +import { motion } from "framer-motion"; +import React from "react"; + +export const SkeletonItem = ({ + className, + delay = 0, +}: { + className: string; + delay?: number; +}) => ( + +); + +export const SkeletonSection = ({ + title, + items, + gridCols = false, +}: { + title?: boolean; + items: number; + gridCols?: boolean; +}) => ( +
+ {title && } +
+ {Array.from({ length: items }).map((_, i) => ( + + ))} +
+
+); diff --git a/apps/web/src/components/presentation/popover.tsx b/apps/web/src/components/presentation/popover.tsx new file mode 100644 index 0000000..ea3b107 --- /dev/null +++ b/apps/web/src/components/presentation/popover.tsx @@ -0,0 +1,177 @@ +// components/Popover.tsx +import { motion, AnimatePresence } from "framer-motion"; +import React, { + ReactNode, + useState, + cloneElement, + isValidElement, + ReactElement, +} from "react"; + +type PopoverPosition = "top" | "bottom" | "left" | "right"; +type TriggerType = "hover" | "click" | "focus"; + +interface PopoverProps { + title?: string; + content: ReactNode; + position?: PopoverPosition; + trigger?: TriggerType; + children: ReactNode; + // 可选的延迟时间(毫秒),用于 hover 模式 + hoverDelay?: number; +} + +const positionStyles = { + top: { + initial: { opacity: 0, y: 10, scale: 0.95 }, + animate: { opacity: 1, y: 0, scale: 1 }, + className: "bottom-full left-1/2 -translate-x-1/2 mb-2", + }, + bottom: { + initial: { opacity: 0, y: -10, scale: 0.95 }, + animate: { opacity: 1, y: 0, scale: 1 }, + className: "top-full left-1/2 -translate-x-1/2 mt-2", + }, + left: { + initial: { opacity: 0, x: 10, scale: 0.95 }, + animate: { opacity: 1, x: 0, scale: 1 }, + className: "right-full top-1/2 -translate-y-1/2 mr-2", + }, + right: { + initial: { opacity: 0, x: -10, scale: 0.95 }, + animate: { opacity: 1, x: 0, scale: 1 }, + className: "left-full top-1/2 -translate-y-1/2 ml-2", + }, +}; + +export const Popover: React.FC = ({ + title, + content, + position = "right", + trigger = "hover", + children, + hoverDelay = 200, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [hoverTimeout, setHoverTimeout] = useState(null); + + const handleMouseEnter = () => { + if (trigger === "hover") { + if (hoverTimeout) window.clearTimeout(hoverTimeout); + setIsOpen(true); + } + }; + + const handleMouseLeave = () => { + if (trigger === "hover") { + // window.setTimeout 返回 number 类型 + const timeout = window.setTimeout( + () => setIsOpen(false), + hoverDelay + ); + setHoverTimeout(timeout); + } + }; + + const handleClick = (e: React.MouseEvent) => { + if (trigger === "click") { + setIsOpen(!isOpen); + } + }; + + const handleFocus = () => { + if (trigger === "focus") { + setIsOpen(true); + } + }; + + const handleBlur = () => { + if (trigger === "focus") { + setIsOpen(false); + } + }; + + // 添加类型断言来处理 children 的类型 + const childrenWithProps = isValidElement(children) + ? cloneElement(children as ReactElement, { + onClick: (e: React.MouseEvent) => { + handleClick(e); + const child = children as ReactElement<{ + onClick?: (e: React.MouseEvent) => void; + }>; + if (child.props.onClick) { + child.props.onClick(e); + } + }, + onMouseEnter: (e: React.MouseEvent) => { + handleMouseEnter(); + const child = children as ReactElement<{ + onMouseEnter?: (e: React.MouseEvent) => void; + }>; + if (child.props.onMouseEnter) { + child.props.onMouseEnter(e); + } + }, + onMouseLeave: (e: React.MouseEvent) => { + handleMouseLeave(); + const child = children as ReactElement<{ + onMouseLeave?: (e: React.MouseEvent) => void; + }>; + if (child.props.onMouseLeave) { + child.props.onMouseLeave(e); + } + }, + onFocus: (e: React.FocusEvent) => { + handleFocus(); + const child = children as ReactElement<{ + onFocus?: (e: React.FocusEvent) => void; + }>; + if (child.props.onFocus) { + child.props.onFocus(e); + } + }, + onBlur: (e: React.FocusEvent) => { + handleBlur(); + const child = children as ReactElement<{ + onBlur?: (e: React.FocusEvent) => void; + }>; + if (child.props.onBlur) { + child.props.onBlur(e); + } + }, + }) + : children; + + return ( +
+ {childrenWithProps} + + + {isOpen && ( + + {title && ( +
+

+ {title} +

+
+ )} +
{content}
+
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx new file mode 100644 index 0000000..4facd0c --- /dev/null +++ b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx @@ -0,0 +1,35 @@ +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); + return ( + +
+ +

+ {loadingProgress > 0 + ? `加载中... ${loadingProgress}%` + : "准备中..."} +

+
+
+ ); +}; +export default LoadingOverlay; diff --git a/apps/web/src/components/presentation/video-player/VideoControls.tsx b/apps/web/src/components/presentation/video-player/VideoControls.tsx new file mode 100644 index 0000000..692f4bb --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoControls.tsx @@ -0,0 +1,276 @@ +import React, { useEffect, useRef, useContext, useState } from "react"; +import Hls from "hls.js"; +import { motion, AnimatePresence } from "framer-motion"; +import { + PlayIcon, + PauseIcon, + SpeakerWaveIcon, + SpeakerXMarkIcon, + Cog6ToothIcon, + ArrowsPointingOutIcon, + ArrowsPointingInIcon, +} from "@heroicons/react/24/solid"; +import "plyr/dist/plyr.css"; +import { VideoPlayerContext } from "./VideoPlayer"; +import { formatTime } from "./utlis"; +import { PlaybackSpeed } from "./type"; + +export const Controls = () => { + const { + showControls, + setShowControls, + isSettingsOpen, + setIsSettingsOpen, + playbackSpeed, + setPlaybackSpeed, + videoRef, + isReady, + setIsReady, + isPlaying, + setIsPlaying, + + bufferingState, + setBufferingState, + volume, + setVolume, + isMuted, + setIsMuted, + loadingProgress, + setLoadingProgress, + currentTime, + setCurrentTime, + duration, + setDuration, + brightness, + setBrightness, + isDragging, + setIsDragging, + isHovering, + 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; + + if (!isHovering && !isDragging) { + timer = window.setTimeout(() => { + setShowControls(false); + }, 2000); + } + + return () => { + if (timer) window.clearTimeout(timer); + }; + }, [isHovering, isDragging]); + return ( + + {/* 进度条 */} +
{ + 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" + /> +
+
+ )} +
+
+ + {/* 全屏按钮 */} + +
+
+ + ); +}; +export default Controls; diff --git a/apps/web/src/components/presentation/video-player/VideoPlayer.tsx b/apps/web/src/components/presentation/video-player/VideoPlayer.tsx new file mode 100644 index 0000000..90fc0bf --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoPlayer.tsx @@ -0,0 +1,115 @@ +import React, { createContext, ReactNode, useRef, useState } from "react"; +import { PlaybackSpeed } from "./type"; +import VideoPlayerLayout from "./VideoPlayerLayout"; + +interface VideoPlayerContextType { + src: string; + poster?: string; + onError?: (error: string) => void; + showControls: boolean; + setShowControls: React.Dispatch>; + isSettingsOpen: boolean; + setIsSettingsOpen: React.Dispatch>; + playbackSpeed: PlaybackSpeed; + setPlaybackSpeed: React.Dispatch>; + videoRef: React.RefObject; + isReady: boolean; + setIsReady: React.Dispatch>; + isPlaying: boolean; + setIsPlaying: React.Dispatch>; + error: string | null; + setError: React.Dispatch>; + bufferingState: boolean; + setBufferingState: React.Dispatch>; + volume: number; + setVolume: React.Dispatch>; + isMuted: boolean; + setIsMuted: React.Dispatch>; + loadingProgress: number; + setLoadingProgress: React.Dispatch>; + currentTime: number; + setCurrentTime: React.Dispatch>; + duration: number; + setDuration: React.Dispatch>; + brightness: number; + setBrightness: React.Dispatch>; + isDragging: boolean; + setIsDragging: React.Dispatch>; + isHovering: boolean; + setIsHovering: React.Dispatch>; + progressRef: React.RefObject; +} +export const VideoPlayerContext = createContext( + null +); +export function VideoPlayer({ + src, + poster, + onError, +}: { + src: string; + poster?: string; + onError?: (error: string) => void; +}) { + const [showControls, setShowControls] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1.0); + + const videoRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [error, setError] = useState(null); + const [bufferingState, setBufferingState] = useState(false); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [loadingProgress, setLoadingProgress] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [brightness, setBrightness] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const progressRef = useRef(null); + + return ( + + + + ); +} diff --git a/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx b/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx new file mode 100644 index 0000000..0da46a8 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx @@ -0,0 +1,34 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "./VideoPlayer"; +import Controls from "./VideoControls"; +import { AnimatePresence } from "framer-motion"; +import { VideoScreen } from "./VideoScreen"; +import LoadingOverlay from "./LoadingOverlay"; + +export default function VideoPlayerLayout() { + const { + isReady, + setIsHovering, + setShowControls, + showControls, + isDragging, + } = useContext(VideoPlayerContext); + + return ( + <> +
{ + setIsHovering(true); + setShowControls(true); + }}> + {!isReady &&
123
} + {!isReady && } + + + {(showControls || isDragging) && } + +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/VideoScreen.tsx b/apps/web/src/components/presentation/video-player/VideoScreen.tsx new file mode 100644 index 0000000..3c8f7c4 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoScreen.tsx @@ -0,0 +1,226 @@ +// 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 { + autoPlay?: boolean; + // className?: string; + // qualities?: { label: string; value: string }[]; + // onQualityChange?: (quality: string) => void; +} +export const VideoScreen: 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, + } = useContext(VideoPlayerContext); + // 处理进度条拖拽 + + const handleProgressDrag = (e: MouseEvent) => { + if (!isDragging || !videoRef.current || !progressRef.current) return; + + const rect = progressRef.current.getBoundingClientRect(); + const percent = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + videoRef.current.currentTime = percent * videoRef.current.duration; + }; + + // 添加拖拽事件监听 + 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]); + + // 添加控制栏组件 + + useEffect(() => { + let hls: Hls; + + const initializeHls = async () => { + if (!videoRef.current) return; + + // Reset states + setIsReady(false); + setError(null); + setLoadingProgress(0); + setBufferingState(false); + + // Check for native HLS support (Safari) + if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { + videoRef.current.src = src; + setIsReady(true); + if (autoPlay) { + try { + await videoRef.current.play(); + setIsPlaying(true); + } catch (error) { + console.log("Auto-play prevented:", error); + } + } + return; + } + + if (!Hls.isSupported()) { + const errorMessage = "您的浏览器不支持 HLS 视频播放"; + setError(errorMessage); + onError?.(errorMessage); + return; + } + + hls = new Hls({ + maxBufferLength: 30, + maxMaxBufferLength: 600, + enableWorker: true, + debug: false, + }); + + hls.loadSource(src); + hls.attachMedia(videoRef.current); + + hls.on(Hls.Events.MANIFEST_PARSED, async () => { + setIsReady(true); + if (autoPlay && videoRef.current) { + try { + await videoRef.current.play(); + setIsPlaying(true); + } catch (error) { + console.log("Auto-play prevented:", error); + } + } + }); + + hls.on(Hls.Events.BUFFER_APPENDING, () => { + setBufferingState(true); + }); + + hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => { + setBufferingState(false); + if (data.stats) { + const progress = + (data.stats.loaded / data.stats.total) * 100; + setLoadingProgress(Math.round(progress)); + } + }); + let networkError; + let fatalError; + hls.on(Hls.Events.ERROR, (_, data) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + networkError = `网络错误: ${data.details}`; + console.error(networkError); + setError(networkError); + onError?.(networkError); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.error("Media error, attempting to recover"); + setError("视频解码错误,尝试恢复..."); + hls.recoverMediaError(); + break; + default: + fatalError = `加载失败: ${data.details}`; + console.error(fatalError); + setError(fatalError); + onError?.(fatalError); + hls.destroy(); + break; + } + } + }); + }; + // Event handlers + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => setIsPlaying(false); + const handleWaiting = () => setBufferingState(true); + const handlePlaying = () => setBufferingState(false); + 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); + } + initializeHls(); + return () => { + if (videoRef.current) { + videoRef.current.removeEventListener("play", handlePlay); + videoRef.current.removeEventListener("pause", handlePause); + videoRef.current.removeEventListener("ended", handleEnded); + videoRef.current.removeEventListener("waiting", handleWaiting); + videoRef.current.removeEventListener("playing", handlePlaying); + } + if (hls) { + hls.destroy(); + } + }; + }, [src, onError, autoPlay]); + + return ( +