From b1c27943cb49d1d45b836b4762d07d5f4d95f1cb Mon Sep 17 00:00:00 2001 From: ditiqi Date: Tue, 21 Jan 2025 20:05:42 +0800 Subject: [PATCH] add --- .../models/resource/pipe/resource.pipeline.ts | 153 ++++---- .../resource/processor/ImageProcessor.ts | 34 +- .../resource/processor/VideoProcessor.ts | 341 ++++++++++-------- apps/server/src/trpc/trpc.service.ts | 71 ++-- apps/web/src/app/main/course/editor/page.tsx | 8 +- .../models/course/list/course-list.tsx | 94 +++-- .../models/course/manage/CourseEditor.tsx | 14 +- .../course/manage/CourseEditorContext.tsx | 201 ++++++----- .../course/manage/CourseEditorHeader.tsx | 1 + .../course/manage/CourseEditorLayout.tsx | 1 + .../manage/CourseForms/CourseBasicForm.tsx | 9 +- .../manage/CourseForms/CourseContentForm.tsx | 49 +++ .../course/manage/CourseForms/CourseForm.tsx | 23 ++ ...getForm copy.tsx => CourseSettingForm.tsx} | 4 +- .../manage/CourseForms/CourseTargetForm.tsx | 46 +++ .../components/models/course/manage/enum.ts | 6 + .../models/course/manage/navItems.tsx | 72 ++-- .../presentation/form/FormDynamicInputs.tsx | 70 ++-- apps/web/src/routes/index.tsx | 16 +- 19 files changed, 702 insertions(+), 511 deletions(-) create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseContentForm.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx rename apps/web/src/components/models/course/manage/CourseForms/{CourseTargetForm copy.tsx => CourseSettingForm.tsx} (88%) create mode 100644 apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx create mode 100644 apps/web/src/components/models/course/manage/enum.ts diff --git a/apps/server/src/models/resource/pipe/resource.pipeline.ts b/apps/server/src/models/resource/pipe/resource.pipeline.ts index ce132da..2c5d98e 100644 --- a/apps/server/src/models/resource/pipe/resource.pipeline.ts +++ b/apps/server/src/models/resource/pipe/resource.pipeline.ts @@ -1,84 +1,85 @@ -import { PrismaClient, Resource } from '@prisma/client' -import { ProcessResult, ResourceProcessor } from '../types' -import { db, ResourceStatus } from '@nice/common' +import { PrismaClient, Resource } from '@prisma/client'; +import { ProcessResult, ResourceProcessor } from '../types'; +import { db, ResourceStatus } from '@nice/common'; import { Logger } from '@nestjs/common'; - // Pipeline 类 export class ResourceProcessingPipeline { - private processors: ResourceProcessor[] = [] - private logger = new Logger(ResourceProcessingPipeline.name); + private processors: ResourceProcessor[] = []; + private logger = new Logger(ResourceProcessingPipeline.name); - constructor() { } + constructor() {} - // 添加处理器 - addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { - this.processors.push(processor) - return this - } - - // 执行处理管道 - async execute(resource: Resource): Promise { - let currentResource = resource - try { - this.logger.log(`开始处理资源: ${resource.id}`) - - currentResource = await this.updateProcessStatus( - resource.id, - ResourceStatus.PROCESSING - ) - this.logger.log(`资源状态已更新为处理中`) - - for (const processor of this.processors) { - const processorName = processor.constructor.name - this.logger.log(`开始执行处理器: ${processorName}`) - - currentResource = await this.updateProcessStatus( - currentResource.id, - processor.constructor.name as ResourceStatus - ) - - currentResource = await processor.process(currentResource) - this.logger.log(`处理器 ${processorName} 执行完成`) - - currentResource = await db.resource.update({ - where: { id: currentResource.id }, - data: currentResource - }) - } - - currentResource = await this.updateProcessStatus( - currentResource.id, - ResourceStatus.PROCESSED - ) - this.logger.log(`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`) - - return { - success: true, - resource: currentResource - } - } catch (error) { - this.logger.error(`资源 ${resource.id} 处理失败:`, error) - - currentResource = await this.updateProcessStatus( - currentResource.id, - ResourceStatus.PROCESS_FAILED - ) - - return { - success: false, - resource: currentResource, - error: error as Error - } - } - } - private async updateProcessStatus( - resourceId: string, - status: ResourceStatus - ): Promise { - return db.resource.update({ - where: { id: resourceId }, - data: { status } - }) + // 添加处理器 + addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { + this.processors.push(processor); + return this; + } + + // 执行处理管道 + async execute(resource: Resource): Promise { + let currentResource = resource; + try { + this.logger.log(`开始处理资源: ${resource.id}`); + + currentResource = await this.updateProcessStatus( + resource.id, + ResourceStatus.PROCESSING, + ); + this.logger.log(`资源状态已更新为处理中`); + + for (const processor of this.processors) { + const processorName = processor.constructor.name; + this.logger.log(`开始执行处理器: ${processorName}`); + + currentResource = await this.updateProcessStatus( + currentResource.id, + processor.constructor.name as ResourceStatus, + ); + + currentResource = await processor.process(currentResource); + this.logger.log(`处理器 ${processorName} 执行完成`); + + currentResource = await db.resource.update({ + where: { id: currentResource.id }, + data: currentResource, + }); + } + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESSED, + ); + this.logger.log( + `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`, + ); + + return { + success: true, + resource: currentResource, + }; + } catch (error) { + this.logger.error(`资源 ${resource.id} 处理失败:`, error); + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESS_FAILED, + ); + + return { + success: false, + resource: currentResource, + error: error as Error, + }; } + } + private async updateProcessStatus( + resourceId: string, + status: ResourceStatus, + ): Promise { + return db.resource.update({ + where: { id: resourceId }, + data: { status }, + }); + } } diff --git a/apps/server/src/models/resource/processor/ImageProcessor.ts b/apps/server/src/models/resource/processor/ImageProcessor.ts index 71cc56e..ea01e75 100644 --- a/apps/server/src/models/resource/processor/ImageProcessor.ts +++ b/apps/server/src/models/resource/processor/ImageProcessor.ts @@ -1,12 +1,14 @@ -import path from "path"; +import path from 'path'; import sharp from 'sharp'; -import { FileMetadata, ImageMetadata, ResourceProcessor } from "../types"; -import { Resource, ResourceStatus, db } from "@nice/common"; -import { getUploadFilePath } from "@server/utils/file"; -import { BaseProcessor } from "./BaseProcessor"; +import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; +import { BaseProcessor } from './BaseProcessor'; export class ImageProcessor extends BaseProcessor { - constructor() { super() } + constructor() { + super(); + } async process(resource: Resource): Promise { const { url } = resource; @@ -23,13 +25,16 @@ export class ImageProcessor extends BaseProcessor { throw new Error(`Failed to get metadata for image: ${url}`); } // Create WebP compressed version - const compressedDir = this.createOutputDir(filepath, "compressed") - const compressedPath = path.join(compressedDir, `${path.basename(filepath, path.extname(filepath))}.webp`); + const compressedDir = this.createOutputDir(filepath, 'compressed'); + const compressedPath = path.join( + compressedDir, + `${path.basename(filepath, path.extname(filepath))}.webp`, + ); await image .webp({ quality: 80, lossless: false, - effort: 5 // Range 0-6, higher means slower but better compression + effort: 5, // Range 0-6, higher means slower but better compression }) .toFile(compressedPath); const imageMeta: ImageMetadata = { @@ -38,15 +43,15 @@ export class ImageProcessor extends BaseProcessor { orientation: metadata.orientation, space: metadata.space, hasAlpha: metadata.hasAlpha, - } + }; const updatedResource = await db.resource.update({ where: { id: resource.id }, data: { metadata: { ...originMeta, - ...imageMeta - } - } + ...imageMeta, + }, + }, }); return updatedResource; @@ -54,5 +59,4 @@ export class ImageProcessor extends BaseProcessor { throw new Error(`Failed to process image: ${error.message}`); } } - -} \ No newline at end of file +} diff --git a/apps/server/src/models/resource/processor/VideoProcessor.ts b/apps/server/src/models/resource/processor/VideoProcessor.ts index 38d0ae2..7853f5e 100644 --- a/apps/server/src/models/resource/processor/VideoProcessor.ts +++ b/apps/server/src/models/resource/processor/VideoProcessor.ts @@ -1,167 +1,190 @@ -import path, { dirname } from "path"; +import path, { dirname } from 'path'; import ffmpeg from 'fluent-ffmpeg'; -import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; -import { Resource, ResourceStatus, db } from "@nice/common"; -import { getUploadFilePath } from "@server/utils/file"; +import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; import fs from 'fs/promises'; import sharp from 'sharp'; -import { BaseProcessor } from "./BaseProcessor"; +import { BaseProcessor } from './BaseProcessor'; export class VideoProcessor extends BaseProcessor { - constructor() { super() } - async process(resource: Resource): Promise { - const { url} = resource; - const filepath = getUploadFilePath(url); - this.logger.log(`Processing video for resource ID: ${resource.id}, File ID: ${url}`); + constructor() { + super(); + } + async process(resource: Resource): Promise { + const { url } = resource; + const filepath = getUploadFilePath(url); + this.logger.log( + `Processing video for resource ID: ${resource.id}, File ID: ${url}`, + ); - const originMeta = resource.metadata as unknown as FileMetadata; - if (!originMeta.mimeType?.startsWith('video/')) { - this.logger.log(`Skipping non-video resource: ${resource.id}`); - return resource; + const originMeta = resource.metadata as unknown as FileMetadata; + if (!originMeta.mimeType?.startsWith('video/')) { + this.logger.log(`Skipping non-video resource: ${resource.id}`); + return resource; + } + + try { + const streamDir = this.createOutputDir(filepath, 'stream'); + + const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ + this.generateM3U8Stream(filepath, streamDir), + this.getVideoMetadata(filepath), + this.generateVideoCover(filepath, dirname(filepath)), + ]); + const videoMeta: VideoMetadata = { + ...videoMetadata, + coverUrl: coverUrl, + }; + const updatedResource = await db.resource.update({ + where: { id: resource.id }, + data: { + metadata: { + ...originMeta, + ...videoMeta, + }, + }, + }); + this.logger.log( + `Successfully processed video for resource ID: ${resource.id}`, + ); + return updatedResource; + } catch (error: any) { + this.logger.error( + `Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`, + ); + throw new Error(`Failed to process video: ${error.message}`); + } + } + private async generateVideoCover( + filepath: string, + outputDir: string, + ): Promise { + this.logger.log(`Generating video cover for: ${filepath}`); + const jpgCoverPath = path.join(outputDir, 'cover.jpg'); + const webpCoverPath = path.join(outputDir, 'cover.webp'); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .on('end', async () => { + try { + // 使用 Sharp 将 JPG 转换为 WebP + await sharp(jpgCoverPath) + .webp({ quality: 80 }) // 设置 WebP 压缩质量 + .toFile(webpCoverPath); + + // 删除临时 JPG 文件 + await fs.unlink(jpgCoverPath); + this.logger.log(`Video cover generated at: ${webpCoverPath}`); + resolve(path.basename(webpCoverPath)); + } catch (error: any) { + this.logger.error( + `Error converting cover to WebP: ${error.message}`, + ); + reject(error); + } + }) + .on('error', (err) => { + this.logger.error(`Error generating video cover: ${err.message}`); + reject(err); + }) + .screenshots({ + count: 1, + folder: outputDir, + filename: 'cover.jpg', + size: '640x360', + }); + }); + } + private async getVideoDuration(filepath: string): Promise { + this.logger.log(`Getting video duration for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video duration: ${err.message}`); + reject(err); + return; } - - try { - const streamDir = this.createOutputDir(filepath, 'stream'); - const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ - this.generateM3U8Stream(filepath, streamDir), - this.getVideoMetadata(filepath), - this.generateVideoCover(filepath, dirname(filepath)) - ]); - - const videoMeta: VideoMetadata = { - ...videoMetadata, - coverUrl: coverUrl, - }; - - const updatedResource = await db.resource.update({ - where: { id: resource.id }, - data: { - metadata: { - ...originMeta, - ...videoMeta, - }, - }, - }); - - this.logger.log(`Successfully processed video for resource ID: ${resource.id}`); - return updatedResource; - } catch (error: any) { - this.logger.error(`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`); - throw new Error(`Failed to process video: ${error.message}`); + const duration = metadata.format.duration || 0; + this.logger.log(`Video duration: ${duration} seconds`); + resolve(duration); + }); + }); + } + private async generateM3U8Stream( + filepath: string, + outputDir: string, + ): Promise { + const m3u8Path = path.join(outputDir, 'index.m3u8'); + this.logger.log( + `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`, + ); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .outputOptions([ + // Improved video encoding settings + '-c:v libx264', + '-preset medium', // Balance between encoding speed and compression + '-crf 23', // Constant Rate Factor for quality + '-profile:v high', // Higher profile for better compression + '-level:v 4.1', // Updated level for better compatibility + // Parallel processing and performance + '-threads 0', // Auto-detect optimal thread count + '-x264-params keyint=48:min-keyint=48', // More precise GOP control + // HLS specific optimizations + '-hls_time 4', // Shorter segment duration for better adaptive streaming + '-hls_list_size 0', // Keep all segments in playlist + '-hls_flags independent_segments+delete_segments', // Allow segment cleanup + // Additional encoding optimizations + '-sc_threshold 0', // Disable scene change detection for more consistent segments + '-max_muxing_queue_size 1024', // Increase muxing queue size + // Output format + '-f hls', + ]) + .output(m3u8Path) + .on('start', (commandLine) => { + this.logger.log(`Starting ffmpeg with command: ${commandLine}`); + }) + .on('end', () => { + this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); + resolve(m3u8Path); + }) + .on('error', (err) => { + const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; + this.logger.error(errorMessage); + reject(new Error(errorMessage)); + }) + .run(); + }); + } + private async getVideoMetadata( + filepath: string, + ): Promise> { + this.logger.log(`Getting video metadata for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video metadata: ${err.message}`); + reject(err); + return; } - } - private async generateVideoCover(filepath: string, outputDir: string): Promise { - this.logger.log(`Generating video cover for: ${filepath}`); - const jpgCoverPath = path.join(outputDir, 'cover.jpg'); - const webpCoverPath = path.join(outputDir, 'cover.webp'); - return new Promise((resolve, reject) => { - ffmpeg(filepath) - .on('end', async () => { - try { - // 使用 Sharp 将 JPG 转换为 WebP - await sharp(jpgCoverPath) - .webp({ quality: 80 }) // 设置 WebP 压缩质量 - .toFile(webpCoverPath); - - // 删除临时 JPG 文件 - await fs.unlink(jpgCoverPath); - - this.logger.log(`Video cover generated at: ${webpCoverPath}`); - resolve(path.basename(webpCoverPath)); - } catch (error: any) { - this.logger.error(`Error converting cover to WebP: ${error.message}`); - reject(error); - } - }) - .on('error', (err) => { - this.logger.error(`Error generating video cover: ${err.message}`); - reject(err); - }) - .screenshots({ - count: 1, - folder: outputDir, - filename: 'cover.jpg', - size: '640x360' - }); - }); - } - private async getVideoDuration(filepath: string): Promise { - this.logger.log(`Getting video duration for file: ${filepath}`); - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filepath, (err, metadata) => { - if (err) { - this.logger.error(`Error getting video duration: ${err.message}`); - reject(err); - return; - } - const duration = metadata.format.duration || 0; - this.logger.log(`Video duration: ${duration} seconds`); - resolve(duration); - }); - }); - } - private async generateM3U8Stream(filepath: string, outputDir: string): Promise { - const m3u8Path = path.join(outputDir, 'index.m3u8'); - this.logger.log(`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`); - return new Promise((resolve, reject) => { - ffmpeg(filepath) - .outputOptions([ - // Improved video encoding settings - '-c:v libx264', - '-preset medium', // Balance between encoding speed and compression - '-crf 23', // Constant Rate Factor for quality - '-profile:v high', // Higher profile for better compression - '-level:v 4.1', // Updated level for better compatibility - // Parallel processing and performance - '-threads 0', // Auto-detect optimal thread count - '-x264-params keyint=48:min-keyint=48', // More precise GOP control - // HLS specific optimizations - '-hls_time 4', // Shorter segment duration for better adaptive streaming - '-hls_list_size 0', // Keep all segments in playlist - '-hls_flags independent_segments+delete_segments', // Allow segment cleanup - // Additional encoding optimizations - '-sc_threshold 0', // Disable scene change detection for more consistent segments - '-max_muxing_queue_size 1024', // Increase muxing queue size - // Output format - '-f hls', - ]) - .output(m3u8Path) - .on('start', (commandLine) => { - this.logger.log(`Starting ffmpeg with command: ${commandLine}`); - }) - .on('end', () => { - this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); - resolve(m3u8Path); - }) - .on('error', (err) => { - const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; - this.logger.error(errorMessage); - reject(new Error(errorMessage)); - }) - .run(); - }); - } - private async getVideoMetadata(filepath: string): Promise> { - this.logger.log(`Getting video metadata for file: ${filepath}`); - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filepath, (err, metadata) => { - if (err) { - this.logger.error(`Error getting video metadata: ${err.message}`); - reject(err); - return; - } - const videoStream = metadata.streams.find(stream => stream.codec_type === 'video'); - const audioStream = metadata.streams.find(stream => stream.codec_type === 'audio'); - const videoMetadata: Partial = { - width: videoStream?.width || 0, - height: videoStream?.height || 0, - duration: metadata.format.duration || 0, - videoCodec: videoStream?.codec_name || '', - audioCodec: audioStream?.codec_name || '' - }; - this.logger.log(`Extracted video metadata: ${JSON.stringify(videoMetadata)}`); - resolve(videoMetadata); - }); - }); - } -} \ No newline at end of file + const videoStream = metadata.streams.find( + (stream) => stream.codec_type === 'video', + ); + const audioStream = metadata.streams.find( + (stream) => stream.codec_type === 'audio', + ); + const videoMetadata: Partial = { + width: videoStream?.width || 0, + height: videoStream?.height || 0, + duration: metadata.format.duration || 0, + videoCodec: videoStream?.codec_name || '', + audioCodec: audioStream?.codec_name || '', + }; + this.logger.log( + `Extracted video metadata: ${JSON.stringify(videoMetadata)}`, + ); + resolve(videoMetadata); + }); + }); + } +} diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts index cbcd25b..31f3072 100755 --- a/apps/server/src/trpc/trpc.service.ts +++ b/apps/server/src/trpc/trpc.service.ts @@ -8,41 +8,44 @@ import { UserProfileService } from '@server/auth/utils'; type Context = Awaited>; @Injectable() export class TrpcService { - private readonly logger = new Logger(TrpcService.name); + private readonly logger = new Logger(TrpcService.name); - async createExpressContext(opts: trpcExpress.CreateExpressContextOptions): Promise<{ staff: UserProfile | undefined }> { - const token = opts.req.headers.authorization?.split(' ')[1]; - return await UserProfileService.instance.getUserProfileByToken(token); + async createExpressContext( + opts: trpcExpress.CreateExpressContextOptions, + ): Promise<{ staff: UserProfile | undefined }> { + const token = opts.req.headers.authorization?.split(' ')[1]; + return await UserProfileService.instance.getUserProfileByToken(token); + } + async createWSSContext( + opts: CreateWSSContextFnOptions, + ): Promise<{ staff: UserProfile | undefined }> { + const token = opts.info.connectionParams?.token; + return await UserProfileService.instance.getUserProfileByToken(token); + } + trpc = initTRPC.context().create({ + transformer: superjson, + errorFormatter: ({ error, shape }) => { + if (error.code !== 'UNAUTHORIZED') { + this.logger.error(error.message, error.stack); + } + return shape; + }, + }); + + procedure = this.trpc.procedure; + router = this.trpc.router; + mergeRouters = this.trpc.mergeRouters; + + // Define a protected procedure that ensures the user is authenticated + protectProcedure = this.procedure.use(async ({ ctx, next }) => { + if (!ctx?.staff) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' }); } - async createWSSContext(opts: CreateWSSContextFnOptions): Promise<{ staff: UserProfile | undefined }> { - const token = opts.info.connectionParams?.token; - return await UserProfileService.instance.getUserProfileByToken(token); - } - trpc = initTRPC.context().create({ - transformer: superjson, - errorFormatter: ({ error, shape }) => { - if (error.code !== 'UNAUTHORIZED') { - this.logger.error(error.message, error.stack); - } - return shape; - } - }); - - procedure = this.trpc.procedure; - router = this.trpc.router; - mergeRouters = this.trpc.mergeRouters; - - // Define a protected procedure that ensures the user is authenticated - protectProcedure = this.procedure.use(async ({ ctx, next }) => { - if (!ctx?.staff) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: "未授权请求" }); - } - return next({ - ctx: { - // User value is confirmed to be non-null at this point - staff: ctx.staff, - }, - }); - + return next({ + ctx: { + // User value is confirmed to be non-null at this point + staff: ctx.staff, + }, }); + }); } diff --git a/apps/web/src/app/main/course/editor/page.tsx b/apps/web/src/app/main/course/editor/page.tsx index 6603f60..8038ffa 100644 --- a/apps/web/src/app/main/course/editor/page.tsx +++ b/apps/web/src/app/main/course/editor/page.tsx @@ -2,7 +2,7 @@ import CourseEditor from "@web/src/components/models/course/manage/CourseEditor" import { useParams } from "react-router-dom"; export function CourseEditorPage() { - const { id } = useParams(); - console.log('Course ID:', id); - return -} \ No newline at end of file + const { id, part } = useParams(); + console.log("Course ID:", id); + return ; +} diff --git a/apps/web/src/components/models/course/list/course-list.tsx b/apps/web/src/components/models/course/list/course-list.tsx index 2da2963..9199f07 100644 --- a/apps/web/src/components/models/course/list/course-list.tsx +++ b/apps/web/src/components/models/course/list/course-list.tsx @@ -3,60 +3,58 @@ import { motion } from "framer-motion"; import { Course, CourseDto } from "@nice/common"; import { EmptyState } from "@web/src/components/presentation/space/Empty"; import { Pagination } from "@web/src/components/presentation/element/Pagination"; - +import React from "react"; interface CourseListProps { - courses?: CourseDto[]; - renderItem: (course: CourseDto) => React.ReactNode; - emptyComponent?: React.ReactNode; - // 新增分页相关属性 - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; + courses?: CourseDto[]; + renderItem: (course: CourseDto) => React.ReactNode; + emptyComponent?: React.ReactNode; + // 新增分页相关属性 + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; } const container = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.05, - duration: 0.3 - }, - }, + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + duration: 0.3, + }, + }, }; export const CourseList = ({ - courses, - renderItem, - emptyComponent: EmptyComponent, - currentPage, - totalPages, - onPageChange, + courses, + renderItem, + emptyComponent: EmptyComponent, + currentPage, + totalPages, + onPageChange, }: CourseListProps) => { - if (!courses || courses.length === 0) { - return EmptyComponent || ; - } + if (!courses || courses.length === 0) { + return EmptyComponent || ; + } + return ( +
+ + {courses.map((course) => ( + + {renderItem(course)} + + ))} + - return ( -
- - {courses.map((course) => ( - - {renderItem(course)} - - ))} - - - -
- ); -}; \ No newline at end of file + +
+ ); +}; diff --git a/apps/web/src/components/models/course/manage/CourseEditor.tsx b/apps/web/src/components/models/course/manage/CourseEditor.tsx index bbb5152..af8fc7c 100644 --- a/apps/web/src/components/models/course/manage/CourseEditor.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditor.tsx @@ -1,12 +1,20 @@ import { CourseBasicForm } from "./CourseForms/CourseBasicForm"; import { CourseFormProvider } from "./CourseEditorContext"; import CourseEditorLayout from "./CourseEditorLayout"; +import { CourseTargetForm } from "./CourseForms/CourseTargetForm"; +import CourseForm from "./CourseForms/CourseForm"; -export default function CourseEditor({ id }: { id?: string }) { +export default function CourseEditor({ + id, + part, +}: { + id?: string; + part?: string; +}) { return ( - + - + ); diff --git a/apps/web/src/components/models/course/manage/CourseEditorContext.tsx b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx index 5e24160..42f9c6f 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx @@ -1,108 +1,119 @@ -import { createContext, useContext, ReactNode, useEffect } from 'react'; -import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { CourseDto, CourseLevel, CourseStatus } from '@nice/common'; -import { api, useCourse } from '@nice/client'; -import toast from 'react-hot-toast'; -import { useNavigate } from 'react-router-dom'; +import { createContext, useContext, ReactNode, useEffect } from "react"; +import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CourseDto, CourseLevel, CourseStatus } from "@nice/common"; +import { api, useCourse } from "@nice/client"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; // 定义课程表单验证 Schema const courseSchema = z.object({ - title: z.string().min(1, '课程标题不能为空'), - subTitle: z.string().nullish(), - description: z.string().nullish(), - thumbnail: z.string().url().nullish(), - level: z.nativeEnum(CourseLevel), - requirements: z.array(z.string()).nullish(), - objectives: z.array(z.string()).nullish(), - skills: z.array(z.string()).nullish(), - audiences: z.array(z.string()).nullish(), - status: z.nativeEnum(CourseStatus), + title: z.string().min(1, "课程标题不能为空"), + subTitle: z.string().nullish(), + description: z.string().nullish(), + thumbnail: z.string().url().nullish(), + level: z.nativeEnum(CourseLevel), + requirements: z.array(z.string()).nullish(), + objectives: z.array(z.string()).nullish(), + skills: z.array(z.string()).nullish(), + audiences: z.array(z.string()).nullish(), + status: z.nativeEnum(CourseStatus), }); export type CourseFormData = z.infer; interface CourseEditorContextType { - onSubmit: SubmitHandler; - editId?: string; // 添加 editId - course?: CourseDto + onSubmit: SubmitHandler; + editId?: string; // 添加 editId + part?: string; + course?: CourseDto; } interface CourseFormProviderProps { - children: ReactNode; - editId?: string; // 添加 editId 参数 + children: ReactNode; + editId?: string; // 添加 editId 参数 + part?: string; } const CourseEditorContext = createContext(null); -export function CourseFormProvider({ children, editId }: CourseFormProviderProps) { - const { create, update } = useCourse() - const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) }) - const methods = useForm({ - resolver: zodResolver(courseSchema), - defaultValues: { - status: CourseStatus.DRAFT, - level: CourseLevel.BEGINNER, - requirements: [], - objectives: [], - skills: [], - audiences: [], - }, - }); - const navigate = useNavigate() - useEffect(() => { - if (course) { - // 只选择表单需要的字段 - const formData = { - title: course.title, - subTitle: course.subTitle, - description: course.description, - thumbnail: course.thumbnail, - level: course.level, - requirements: course.requirements, - objectives: course.objectives, - skills: course.skills, - audiences: course.audiences, - status: course.status, - }; - methods.reset(formData as any); - } - }, [course, methods]); - const onSubmit: SubmitHandler = async (data: CourseFormData) => { - try { - if (editId) { - await update.mutateAsync({ - where: { id: editId }, - data: { - ...data - } - }) - toast.success('课程更新成功!'); - } else { - const result = await create.mutateAsync({ - data: { - ...data - } - }) - console.log(`/course/${result.id}/manage`) - navigate(`/course/${result.id}/manage`, { replace: true }) - toast.success('课程创建成功!'); - } +export function CourseFormProvider({ + children, + editId, + part, +}: CourseFormProviderProps) { + const { create, update } = useCourse(); + const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery( + { where: { id: editId } }, + { enabled: Boolean(editId) } + ); + const methods = useForm({ + resolver: zodResolver(courseSchema), + defaultValues: { + status: CourseStatus.DRAFT, + level: CourseLevel.BEGINNER, + requirements: [], + objectives: [], + skills: [], + audiences: [], + }, + }); + const navigate = useNavigate(); + useEffect(() => { + if (course) { + // 只选择表单需要的字段 + const formData = { + title: course.title, + subTitle: course.subTitle, + description: course.description, + thumbnail: course.thumbnail, + level: course.level, + requirements: course.requirements, + objectives: course.objectives, + skills: course.skills, + audiences: course.audiences, + status: course.status, + }; + methods.reset(formData as any); + } + }, [course, methods]); + const onSubmit: SubmitHandler = async ( + data: CourseFormData + ) => { + try { + if (editId) { + await update.mutateAsync({ + where: { id: editId }, + data: { + ...data, + }, + }); + toast.success("课程更新成功!"); + } else { + const result = await create.mutateAsync({ + data: { + ...data, + }, + }); + console.log(`/course/${result.id}/manage`); + navigate(`/course/${result.id}/manage`, { replace: true }); + toast.success("课程创建成功!"); + } + } catch (error) { + console.error("Error submitting form:", error); + toast.error("操作失败,请重试!"); + } + }; - } catch (error) { - console.error('Error submitting form:', error); - toast.error('操作失败,请重试!'); - } - }; - - return ( - - - {children} - - - ); + return ( + + {children} + + ); } export const useCourseEditor = () => { - const context = useContext(CourseEditorContext); - if (!context) { - throw new Error('useCourseEditor must be used within CourseFormProvider'); - } - return context; -}; \ No newline at end of file + const context = useContext(CourseEditorContext); + if (!context) { + throw new Error( + "useCourseEditor must be used within CourseFormProvider" + ); + } + return context; +}; diff --git a/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx index d3e93b9..9184645 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx @@ -13,6 +13,7 @@ const courseStatusVariant: Record = { }; export default function CourseEditorHeader() { const navigate = useNavigate(); + const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext() const { onSubmit, course } = useCourseEditor() return ( diff --git a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx index 08ae53e..f4ffb73 100644 --- a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx +++ b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx @@ -12,6 +12,7 @@ interface CourseEditorLayoutProps { export default function CourseEditorLayout({ children, }: CourseEditorLayoutProps) { + const [isHovered, setIsHovered] = useState(false); const [selectedSection, setSelectedSection] = useState(0); const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx index f9042ac..937d065 100644 --- a/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseBasicForm.tsx @@ -7,6 +7,7 @@ import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { convertToOptions } from "@nice/client"; import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; +import { useEffect } from "react"; export function CourseBasicForm() { const { @@ -15,7 +16,9 @@ export function CourseBasicForm() { watch, handleSubmit, } = useFormContext(); - + useEffect(() => { + console.log(watch("audiences")); + }, [watch("audiences")]); return (
- - (); + useEffect(() => { + console.log(watch("audiences")); + }, [watch("audiences")]); + return ( + + + + + + {/* */} + + ); +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx new file mode 100644 index 0000000..ecd74cf --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseForm.tsx @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { useCourseEditor } from "../CourseEditorContext"; +import { CoursePart } from "../enum"; +import { CourseBasicForm } from "./CourseBasicForm"; +import { CourseTargetForm } from "./CourseTargetForm"; +import { CourseContentForm } from "./CourseContentForm"; + +export default function CourseForm() { + const { part } = useCourseEditor(); + if (part === CoursePart.OVERVIEW) { + return ; + } + if (part === CoursePart.TARGET) { + return ; + } + if (part === CoursePart.CONTENT) { + return ; + } + if (part === CoursePart.SETTING) { + return <>; + } + return ; +} diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx similarity index 88% rename from apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx rename to apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx index 557aa55..0dd1902 100644 --- a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm copy.tsx +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseSettingForm.tsx @@ -6,8 +6,10 @@ import { FormInput } from "@web/src/components/presentation/form/FormInput"; import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; +import { useEffect } from "react"; -export function CourseBasicForm() { +export function CourseContentForm() { const { register, formState: { errors }, diff --git a/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx new file mode 100644 index 0000000..50a4d82 --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseForms/CourseTargetForm.tsx @@ -0,0 +1,46 @@ +import { SubmitHandler, useFormContext } from "react-hook-form"; + +import { CourseFormData, useCourseEditor } from "../CourseEditorContext"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { FormInput } from "@web/src/components/presentation/form/FormInput"; +import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; +import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; +import { convertToOptions } from "@nice/client"; +import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; + +export function CourseTargetForm() { + const { + register, + formState: { errors }, + watch, + handleSubmit, + } = useFormContext(); + + return ( +
+ + + + + + {/* */} +
+ ); +} diff --git a/apps/web/src/components/models/course/manage/enum.ts b/apps/web/src/components/models/course/manage/enum.ts new file mode 100644 index 0000000..81a97a6 --- /dev/null +++ b/apps/web/src/components/models/course/manage/enum.ts @@ -0,0 +1,6 @@ +export enum CoursePart { + OVERVIEW = "overview", + TARGET = "target", + CONTENT = "content", + SETTING = "settings", +} diff --git a/apps/web/src/components/models/course/manage/navItems.tsx b/apps/web/src/components/models/course/manage/navItems.tsx index b2342ae..b5eb763 100644 --- a/apps/web/src/components/models/course/manage/navItems.tsx +++ b/apps/web/src/components/models/course/manage/navItems.tsx @@ -5,26 +5,54 @@ import { VideoCameraIcon, } from "@heroicons/react/24/outline"; import { NavItem } from "@nice/client"; +import { CoursePart } from "./enum"; +export const DEFAULT_NAV_ITEMS = ( + courseId?: string +): (NavItem & { isCompleted?: boolean })[] => { + const basePath = courseId ? `/course/${courseId}` : "/course"; -export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ - { - label: "课程概述", - icon: , - path: "/manage/overview", - }, - { - label: "目标学员", - icon: , - path: "/manage/target", - }, - { - label: "课程内容", - icon: , - path: "/manage/content", - }, - { - label: "课程设置", - icon: , - path: "/manage/settings", - }, -]; + return [ + { + label: "课程概述", + icon: , + path: `${basePath}/manage/${CoursePart.OVERVIEW}`, + }, + { + label: "目标学员", + icon: , + path: `${basePath}/manage/${CoursePart.TARGET}`, + }, + { + label: "课程内容", + icon: , + path: `${basePath}/manage/${CoursePart.CONTENT}`, + }, + { + label: "课程设置", + icon: , + path: `${basePath}/manage/${CoursePart.SETTING}`, + }, + ]; +}; +// export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ +// { +// label: "课程概述", +// icon: , +// path: `/course/${}/manage/${CoursePart.OVERVIEW}`, +// }, +// { +// label: "目标学员", +// icon: , +// path: `/manage/${CoursePart.TARGET}`, +// }, +// { +// label: "课程内容", +// icon: , +// path: `/manage/${CoursePart.CONTENT}`, +// }, +// { +// label: "课程设置", +// icon: , +// path: `/manage/${CoursePart.SETTING}`, +// }, +// ]; diff --git a/apps/web/src/components/presentation/form/FormDynamicInputs.tsx b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx index ec64367..6f2a9d2 100644 --- a/apps/web/src/components/presentation/form/FormDynamicInputs.tsx +++ b/apps/web/src/components/presentation/form/FormDynamicInputs.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"; import React, { useState } from "react"; import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; import FormError from "./FormError"; +import { TrashIcon } from "@heroicons/react/24/solid"; export interface DynamicFormInputProps extends Omit< @@ -11,6 +12,8 @@ export interface DynamicFormInputProps "type" > { name: string; + addTitle?: string; + subTitle?: string; label: string; type?: | "text" @@ -29,7 +32,9 @@ export interface DynamicFormInputProps export function FormDynamicInputs({ name, + addTitle, label, + subTitle, type = "text", rows = 4, className, @@ -49,7 +54,14 @@ export function FormDynamicInputs({ control, name, }); - + // 添加 onChange 处理函数 + const handleInputChange = (index: number, value: string) => { + setValue(`${name}.${index}`, value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }; const handleBlur = async (index: number) => { setFocusedIndexes(focusedIndexes.filter((i) => i !== index)); await trigger(`${name}.${index}`); @@ -65,12 +77,16 @@ export function FormDynamicInputs({ `; const InputElement = type === "textarea" ? "textarea" : "input"; - return (
+ {subTitle && ( + + )} {fields.map((field, index) => ( @@ -83,7 +99,13 @@ export function FormDynamicInputs({ className="group relative">
+ handleInputChange( + index, + e.target.value + ), + })} type={type !== "textarea" ? type : undefined} rows={type === "textarea" ? rows : undefined} {...restProps} @@ -97,40 +119,20 @@ export function FormDynamicInputs({ className={inputClasses} /> -
- {values[index] && - focusedIndexes.includes(index) && ( - - )} + {/* 修改这部分,将删除按钮放在 input 内部右侧 */} +
{values[index] && !fieldErrors?.[index] && ( )} + {fields.length > 1 && ( + + )}
- - {index > 0 && ( - remove(index)} - className="absolute -right-2 -top-2 p-1 bg-red-500 rounded-full - text-white shadow-sm opacity-0 group-hover:opacity-100 - transition-opacity duration-200"> - - - )}
{fieldErrors?.[index]?.message && ( @@ -147,7 +149,7 @@ export function FormDynamicInputs({ className="flex items-center gap-1 text-blue-500 hover:text-blue-600 transition-colors px-4 py-2 rounded-lg hover:bg-blue-50"> - 添加新{label} + 添加新{addTitle || label}
); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a2ef037..97bbe82 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -84,22 +84,8 @@ export const routes: CustomRouteObject[] = [ path: "course", children: [ { - path: ":id?/manage", // 使用 ? 表示 id 参数是可选的 + path: ":id?/manage/:part?", // 使用 ? 表示 id 参数是可选的 element: , - children: [ - { - index: true, // This will make :id?/manage the default route - element: , - }, - { - path: "overview", - element: , // You might want to create a specific overview component - }, - { - path: "target", - element: , // Create a specific target page component - }, - ], }, { path: ":id?/detail", // 使用 ? 表示 id 参数是可选的