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/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 5464db8..92920de 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/common/form/FormArrayField.tsx b/apps/web/src/components/common/form/FormArrayField.tsx index c57e299..41d2a0d 100644 --- a/apps/web/src/components/common/form/FormArrayField.tsx +++ b/apps/web/src/components/common/form/FormArrayField.tsx @@ -1,106 +1,150 @@ -import { useFormContext } from 'react-hook-form'; -import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { Reorder } from 'framer-motion'; -import { useEffect, useState } from 'react'; -import FormError from './FormError'; -import { UUIDGenerator } from '@nice/common'; +import { useFormContext } from "react-hook-form"; +import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Reorder } from "framer-motion"; +import React, { useEffect, 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 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(); - // 添加 watch 监听 - const watchedValues = watch(name); - const [items, setItems] = useState(() => - (watchedValues as string[])?.map(value => ({ id: UUIDGenerator.generate(), value })) || [] - ); - // 使用 useEffect 监听表单值变化 - useEffect(() => { - if (watchedValues) { - const newItems = watchedValues.map((value: string, index: number) => { - // 尽量保持现有的 id - return { id: items[index]?.id || UUIDGenerator.generate(), value }; - }); - setItems(newItems); - } - }, [watchedValues]); - const error = errors[name]?.message as string; - const updateItems = (newItems: ItemType[]) => { - setItems(newItems); - setValue(name, newItems.map(item => item.value), { shouldDirty: true }); - }; - return ( -
- -
- - {items.map((item, index) => ( - -
-
- { - const newItems = items.map(i => - i.id === item.id ? { ...i, value: e.target.value } : i - ); - updateItems(newItems); - }} - onBlur={() => { - trigger(name) - }} - placeholder={placeholder} - className={inputStyles} - /> - {inputProps.maxLength && ( - - {inputProps.maxLength - (item.value?.length || 0)} - - )} -
- -
-
- ))} -
+ // 添加 watch 监听 + const watchedValues = watch(name); + const [items, setItems] = useState( + () => + (watchedValues as string[])?.map((value) => ({ + id: UUIDGenerator.generate(), + value, + })) || [] + ); + // 使用 useEffect 监听表单值变化 + useEffect(() => { + if (watchedValues) { + const newItems = watchedValues.map( + (value: string, index: number) => { + // 尽量保持现有的 id + return { + id: items[index]?.id || UUIDGenerator.generate(), + value, + }; + } + ); + setItems(newItems); + } + }, [watchedValues]); + const error = errors[name]?.message as string; + const updateItems = (newItems: ItemType[]) => { + setItems(newItems); + setValue( + name, + newItems.map((item) => item.value), + { shouldDirty: true } + ); + }; + return ( +
+ +
+ + {items.map((item, index) => ( + +
+
+ { + const newItems = items.map((i) => + i.id === item.id + ? { + ...i, + value: e.target + .value, + } + : i + ); + updateItems(newItems); + }} + 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/common/form/FormDynamicInputs.tsx b/apps/web/src/components/common/form/FormDynamicInputs.tsx new file mode 100644 index 0000000..6f2a9d2 --- /dev/null +++ b/apps/web/src/components/common/form/FormDynamicInputs.tsx @@ -0,0 +1,156 @@ +// 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"; +import { TrashIcon } from "@heroicons/react/24/solid"; + +export interface DynamicFormInputProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + addTitle?: string; + subTitle?: string; + label: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + rows?: number; +} + +export function FormDynamicInputs({ + name, + addTitle, + label, + subTitle, + 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, + }); + // 添加 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}`); + }; + + 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 ( +
+ + {subTitle && ( + + )} + + + {fields.map((field, index) => ( + +
+ + handleInputChange( + index, + e.target.value + ), + })} + type={type !== "textarea" ? type : undefined} + rows={type === "textarea" ? rows : undefined} + {...restProps} + onFocus={() => + setFocusedIndexes([ + ...focusedIndexes, + index, + ]) + } + onBlur={() => handleBlur(index)} + className={inputClasses} + /> + + {/* 修改这部分,将删除按钮放在 input 内部右侧 */} +
+ {values[index] && !fieldErrors?.[index] && ( + + )} + {fields.length > 1 && ( + + )} +
+
+ {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"> + + 添加新{addTitle || label} + +
+ ); +} 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 fc3cf71..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/editor/context/CourseEditorContext.tsx b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx index 549cf66..55c8104 100644 --- a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx @@ -1,11 +1,11 @@ -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, '课程标题不能为空'), @@ -18,13 +18,15 @@ const courseSchema = z.object({ }); 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) { @@ -93,9 +95,11 @@ export function CourseFormProvider({ children, editId }: CourseFormProviderProps } 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/editor/layout/CourseEditorHeader.tsx b/apps/web/src/components/models/course/editor/layout/CourseEditorHeader.tsx index d816e5f..53a6c5d 100644 --- a/apps/web/src/components/models/course/editor/layout/CourseEditorHeader.tsx +++ b/apps/web/src/components/models/course/editor/layout/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() diff --git a/apps/web/src/components/models/course/editor/layout/CourseForms/CourseBasicForm.tsx b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseBasicForm.tsx new file mode 100644 index 0000000..b908e2b --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseBasicForm.tsx @@ -0,0 +1,47 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData } from "../../context/CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/common/form/FormInput"; +import { FormSelect } from "@web/src/components/common/form/FormSelect"; +import { convertToOptions } from "@nice/client"; +import { useEffect } from "react"; + +export function CourseBasicForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + // useEffect(() => { + // console.log(watch("audiences")); + // }, [watch("audiences")]); + return ( +
+ + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/editor/layout/CourseForms/CourseContentForm.tsx b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseContentForm.tsx new file mode 100644 index 0000000..9875740 --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseContentForm.tsx @@ -0,0 +1,47 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData } from "../../context/CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/common/form/FormInput"; +import { FormSelect } from "@web/src/components/common/form/FormSelect"; +import { convertToOptions } from "@nice/client"; +import { useEffect } from "react"; + +export function CourseContentForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + // useEffect(() => { + // console.log(watch("audiences")); + // }, [watch("audiences")]); + return ( +
+ + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/editor/layout/CourseForms/CourseForm.tsx b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseForm.tsx new file mode 100644 index 0000000..3743403 --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseForm.tsx @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { useCourseEditor } from "../../context/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/editor/layout/CourseForms/CourseSettingForm.tsx b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseSettingForm.tsx new file mode 100644 index 0000000..8bab274 --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseSettingForm.tsx @@ -0,0 +1,44 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData } from "../../context/CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/common/form/FormInput"; +import { FormSelect } from "@web/src/components/common/form/FormSelect"; +import { convertToOptions } from "@nice/client"; + +export function CourseContentForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/editor/layout/CourseForms/CourseTargetForm.tsx b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseTargetForm.tsx new file mode 100644 index 0000000..2b9bf07 --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/CourseForms/CourseTargetForm.tsx @@ -0,0 +1,43 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData } from "../../context/CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/common/form/FormDynamicInputs"; + +export function CourseTargetForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + + {/* */} +
+ ); +} diff --git a/apps/web/src/components/models/course/editor/layout/enum.ts b/apps/web/src/components/models/course/editor/layout/enum.ts new file mode 100644 index 0000000..81a97a6 --- /dev/null +++ b/apps/web/src/components/models/course/editor/layout/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/list/course-list.tsx b/apps/web/src/components/models/course/list/course-list.tsx index 350e9ce..64c8e83 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,59 @@ import { motion } from "framer-motion"; import { Course, CourseDto } from "@nice/common"; import { EmptyState } from "@web/src/components/common/space/Empty"; import { Pagination } from "@web/src/components/common/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/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/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/VideoControls.tsx b/apps/web/src/components/presentation/video-player/VideoControls.tsx index 9ee519e..93fc540 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,10 +8,19 @@ import { Cog6ToothIcon, ArrowsPointingOutIcon, ArrowsPointingInIcon, + ChevronUpDownIcon, + SunIcon, } from "@heroicons/react/24/solid"; 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 { @@ -26,6 +34,8 @@ export const Controls = () => { isReady, setIsReady, isPlaying, + setIsSpeedOpen, + isSpeedOpen, setIsPlaying, bufferingState, @@ -45,16 +55,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; @@ -83,190 +89,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 88% rename from apps/web/src/components/presentation/video-player/VideoScreen.tsx rename to apps/web/src/components/presentation/video-player/VideoDisplay.tsx index b7db334..fb1b40d 100644 --- a/apps/web/src/components/presentation/video-player/VideoScreen.tsx +++ b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx @@ -1,59 +1,37 @@ -// VideoPlayer.tsx import React, { useContext, useEffect, useRef, useState } from "react"; import Hls from "hls.js"; 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, @@ -66,23 +44,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; @@ -96,6 +70,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(); @@ -126,6 +104,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(); @@ -139,7 +121,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) { @@ -148,8 +129,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) { @@ -176,20 +157,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); @@ -197,6 +192,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(); @@ -216,7 +215,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 8b213cb..d623ec5 100755 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -123,4 +123,4 @@ .custom-table .ant-table-tbody>tr:last-child>td { border-bottom: none; /* 去除最后一行的底部边框 */ -} \ No newline at end of file +}