This commit is contained in:
ditiqi 2025-01-21 20:05:42 +08:00
parent 4ac8c07215
commit b1c27943cb
19 changed files with 702 additions and 511 deletions

View File

@ -1,84 +1,85 @@
import { PrismaClient, Resource } from '@prisma/client' import { PrismaClient, Resource } from '@prisma/client';
import { ProcessResult, ResourceProcessor } from '../types' import { ProcessResult, ResourceProcessor } from '../types';
import { db, ResourceStatus } from '@nice/common' import { db, ResourceStatus } from '@nice/common';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
// Pipeline 类 // Pipeline 类
export class ResourceProcessingPipeline { export class ResourceProcessingPipeline {
private processors: ResourceProcessor[] = [] private processors: ResourceProcessor[] = [];
private logger = new Logger(ResourceProcessingPipeline.name); private logger = new Logger(ResourceProcessingPipeline.name);
constructor() { } constructor() {}
// 添加处理器 // 添加处理器
addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline {
this.processors.push(processor) this.processors.push(processor);
return this return this;
} }
// 执行处理管道 // 执行处理管道
async execute(resource: Resource): Promise<ProcessResult> { async execute(resource: Resource): Promise<ProcessResult> {
let currentResource = resource let currentResource = resource;
try { try {
this.logger.log(`开始处理资源: ${resource.id}`) this.logger.log(`开始处理资源: ${resource.id}`);
currentResource = await this.updateProcessStatus( currentResource = await this.updateProcessStatus(
resource.id, resource.id,
ResourceStatus.PROCESSING ResourceStatus.PROCESSING,
) );
this.logger.log(`资源状态已更新为处理中`) this.logger.log(`资源状态已更新为处理中`);
for (const processor of this.processors) { for (const processor of this.processors) {
const processorName = processor.constructor.name const processorName = processor.constructor.name;
this.logger.log(`开始执行处理器: ${processorName}`) this.logger.log(`开始执行处理器: ${processorName}`);
currentResource = await this.updateProcessStatus( currentResource = await this.updateProcessStatus(
currentResource.id, currentResource.id,
processor.constructor.name as ResourceStatus processor.constructor.name as ResourceStatus,
) );
currentResource = await processor.process(currentResource) currentResource = await processor.process(currentResource);
this.logger.log(`处理器 ${processorName} 执行完成`) this.logger.log(`处理器 ${processorName} 执行完成`);
currentResource = await db.resource.update({ currentResource = await db.resource.update({
where: { id: currentResource.id }, where: { id: currentResource.id },
data: currentResource data: currentResource,
}) });
} }
currentResource = await this.updateProcessStatus( currentResource = await this.updateProcessStatus(
currentResource.id, currentResource.id,
ResourceStatus.PROCESSED ResourceStatus.PROCESSED,
) );
this.logger.log(`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`) this.logger.log(
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`,
return { );
success: true,
resource: currentResource return {
} success: true,
} catch (error) { resource: currentResource,
this.logger.error(`资源 ${resource.id} 处理失败:`, error) };
} catch (error) {
currentResource = await this.updateProcessStatus( this.logger.error(`资源 ${resource.id} 处理失败:`, error);
currentResource.id,
ResourceStatus.PROCESS_FAILED currentResource = await this.updateProcessStatus(
) currentResource.id,
ResourceStatus.PROCESS_FAILED,
return { );
success: false,
resource: currentResource, return {
error: error as Error success: false,
} resource: currentResource,
} error: error as Error,
} };
private async updateProcessStatus(
resourceId: string,
status: ResourceStatus
): Promise<Resource> {
return db.resource.update({
where: { id: resourceId },
data: { status }
})
} }
}
private async updateProcessStatus(
resourceId: string,
status: ResourceStatus,
): Promise<Resource> {
return db.resource.update({
where: { id: resourceId },
data: { status },
});
}
} }

View File

@ -1,12 +1,14 @@
import path from "path"; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { FileMetadata, ImageMetadata, ResourceProcessor } from "../types"; import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types';
import { Resource, ResourceStatus, db } from "@nice/common"; import { Resource, ResourceStatus, db } from '@nice/common';
import { getUploadFilePath } from "@server/utils/file"; import { getUploadFilePath } from '@server/utils/file';
import { BaseProcessor } from "./BaseProcessor"; import { BaseProcessor } from './BaseProcessor';
export class ImageProcessor extends BaseProcessor { export class ImageProcessor extends BaseProcessor {
constructor() { super() } constructor() {
super();
}
async process(resource: Resource): Promise<Resource> { async process(resource: Resource): Promise<Resource> {
const { url } = resource; const { url } = resource;
@ -23,13 +25,16 @@ export class ImageProcessor extends BaseProcessor {
throw new Error(`Failed to get metadata for image: ${url}`); throw new Error(`Failed to get metadata for image: ${url}`);
} }
// Create WebP compressed version // Create WebP compressed version
const compressedDir = this.createOutputDir(filepath, "compressed") const compressedDir = this.createOutputDir(filepath, 'compressed');
const compressedPath = path.join(compressedDir, `${path.basename(filepath, path.extname(filepath))}.webp`); const compressedPath = path.join(
compressedDir,
`${path.basename(filepath, path.extname(filepath))}.webp`,
);
await image await image
.webp({ .webp({
quality: 80, quality: 80,
lossless: false, 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); .toFile(compressedPath);
const imageMeta: ImageMetadata = { const imageMeta: ImageMetadata = {
@ -38,15 +43,15 @@ export class ImageProcessor extends BaseProcessor {
orientation: metadata.orientation, orientation: metadata.orientation,
space: metadata.space, space: metadata.space,
hasAlpha: metadata.hasAlpha, hasAlpha: metadata.hasAlpha,
} };
const updatedResource = await db.resource.update({ const updatedResource = await db.resource.update({
where: { id: resource.id }, where: { id: resource.id },
data: { data: {
metadata: { metadata: {
...originMeta, ...originMeta,
...imageMeta ...imageMeta,
} },
} },
}); });
return updatedResource; return updatedResource;
@ -54,5 +59,4 @@ export class ImageProcessor extends BaseProcessor {
throw new Error(`Failed to process image: ${error.message}`); throw new Error(`Failed to process image: ${error.message}`);
} }
} }
}
}

View File

@ -1,167 +1,190 @@
import path, { dirname } from "path"; import path, { dirname } from 'path';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
import { Resource, ResourceStatus, db } from "@nice/common"; import { Resource, ResourceStatus, db } from '@nice/common';
import { getUploadFilePath } from "@server/utils/file"; import { getUploadFilePath } from '@server/utils/file';
import fs from 'fs/promises'; import fs from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
import { BaseProcessor } from "./BaseProcessor"; import { BaseProcessor } from './BaseProcessor';
export class VideoProcessor extends BaseProcessor { export class VideoProcessor extends BaseProcessor {
constructor() { super() } constructor() {
async process(resource: Resource): Promise<Resource> { super();
const { url} = resource; }
const filepath = getUploadFilePath(url); async process(resource: Resource): Promise<Resource> {
this.logger.log(`Processing video for resource ID: ${resource.id}, File ID: ${url}`); 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; const originMeta = resource.metadata as unknown as FileMetadata;
if (!originMeta.mimeType?.startsWith('video/')) { if (!originMeta.mimeType?.startsWith('video/')) {
this.logger.log(`Skipping non-video resource: ${resource.id}`); this.logger.log(`Skipping non-video resource: ${resource.id}`);
return resource; 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<string> {
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<number> {
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;
try { this.logger.log(`Video duration: ${duration} seconds`);
const streamDir = this.createOutputDir(filepath, 'stream'); resolve(duration);
const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ });
this.generateM3U8Stream(filepath, streamDir), });
this.getVideoMetadata(filepath), }
this.generateVideoCover(filepath, dirname(filepath)) private async generateM3U8Stream(
]); filepath: string,
outputDir: string,
const videoMeta: VideoMetadata = { ): Promise<string> {
...videoMetadata, const m3u8Path = path.join(outputDir, 'index.m3u8');
coverUrl: coverUrl, this.logger.log(
}; `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`,
);
const updatedResource = await db.resource.update({ return new Promise<string>((resolve, reject) => {
where: { id: resource.id }, ffmpeg(filepath)
data: { .outputOptions([
metadata: { // Improved video encoding settings
...originMeta, '-c:v libx264',
...videoMeta, '-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
this.logger.log(`Successfully processed video for resource ID: ${resource.id}`); '-threads 0', // Auto-detect optimal thread count
return updatedResource; '-x264-params keyint=48:min-keyint=48', // More precise GOP control
} catch (error: any) { // HLS specific optimizations
this.logger.error(`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`); '-hls_time 4', // Shorter segment duration for better adaptive streaming
throw new Error(`Failed to process video: ${error.message}`); '-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<Partial<VideoMetadata>> {
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(
private async generateVideoCover(filepath: string, outputDir: string): Promise<string> { (stream) => stream.codec_type === 'video',
this.logger.log(`Generating video cover for: ${filepath}`); );
const jpgCoverPath = path.join(outputDir, 'cover.jpg'); const audioStream = metadata.streams.find(
const webpCoverPath = path.join(outputDir, 'cover.webp'); (stream) => stream.codec_type === 'audio',
return new Promise((resolve, reject) => { );
ffmpeg(filepath) const videoMetadata: Partial<VideoMetadata> = {
.on('end', async () => { width: videoStream?.width || 0,
try { height: videoStream?.height || 0,
// 使用 Sharp 将 JPG 转换为 WebP duration: metadata.format.duration || 0,
await sharp(jpgCoverPath) videoCodec: videoStream?.codec_name || '',
.webp({ quality: 80 }) // 设置 WebP 压缩质量 audioCodec: audioStream?.codec_name || '',
.toFile(webpCoverPath); };
this.logger.log(
// 删除临时 JPG 文件 `Extracted video metadata: ${JSON.stringify(videoMetadata)}`,
await fs.unlink(jpgCoverPath); );
resolve(videoMetadata);
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<number> {
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<string> {
const m3u8Path = path.join(outputDir, 'index.m3u8');
this.logger.log(`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`);
return new Promise<string>((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<Partial<VideoMetadata>> {
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<VideoMetadata> = {
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);
});
});
}
}

View File

@ -8,41 +8,44 @@ import { UserProfileService } from '@server/auth/utils';
type Context = Awaited<ReturnType<TrpcService['createExpressContext']>>; type Context = Awaited<ReturnType<TrpcService['createExpressContext']>>;
@Injectable() @Injectable()
export class TrpcService { 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 }> { async createExpressContext(
const token = opts.req.headers.authorization?.split(' ')[1]; opts: trpcExpress.CreateExpressContextOptions,
return await UserProfileService.instance.getUserProfileByToken(token); ): 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<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 }> { return next({
const token = opts.info.connectionParams?.token; ctx: {
return await UserProfileService.instance.getUserProfileByToken(token); // User value is confirmed to be non-null at this point
} staff: ctx.staff,
trpc = initTRPC.context<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,
},
});
}); });
});
} }

View File

@ -2,7 +2,7 @@ import CourseEditor from "@web/src/components/models/course/manage/CourseEditor"
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
export function CourseEditorPage() { export function CourseEditorPage() {
const { id } = useParams(); const { id, part } = useParams();
console.log('Course ID:', id); console.log("Course ID:", id);
return <CourseEditor id={id} ></CourseEditor> return <CourseEditor id={id} part={part}></CourseEditor>;
} }

View File

@ -3,60 +3,58 @@ import { motion } from "framer-motion";
import { Course, CourseDto } from "@nice/common"; import { Course, CourseDto } from "@nice/common";
import { EmptyState } from "@web/src/components/presentation/space/Empty"; import { EmptyState } from "@web/src/components/presentation/space/Empty";
import { Pagination } from "@web/src/components/presentation/element/Pagination"; import { Pagination } from "@web/src/components/presentation/element/Pagination";
import React from "react";
interface CourseListProps { interface CourseListProps {
courses?: CourseDto[]; courses?: CourseDto[];
renderItem: (course: CourseDto) => React.ReactNode; renderItem: (course: CourseDto) => React.ReactNode;
emptyComponent?: React.ReactNode; emptyComponent?: React.ReactNode;
// 新增分页相关属性 // 新增分页相关属性
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
} }
const container = { const container = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { show: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.05, staggerChildren: 0.05,
duration: 0.3 duration: 0.3,
}, },
}, },
}; };
export const CourseList = ({ export const CourseList = ({
courses, courses,
renderItem, renderItem,
emptyComponent: EmptyComponent, emptyComponent: EmptyComponent,
currentPage, currentPage,
totalPages, totalPages,
onPageChange, onPageChange,
}: CourseListProps) => { }: CourseListProps) => {
if (!courses || courses.length === 0) { if (!courses || courses.length === 0) {
return EmptyComponent || <EmptyState />; return EmptyComponent || <EmptyState />;
} }
return (
<div>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{courses.map((course) => (
<motion.div key={course.id}>
{renderItem(course)}
</motion.div>
))}
</motion.div>
return ( <Pagination
<div> currentPage={currentPage}
<motion.div totalPages={totalPages}
variants={container} onPageChange={onPageChange}
initial="hidden" />
animate="show" </div>
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" );
> };
{courses.map((course) => (
<motion.div key={course.id}>
{renderItem(course)}
</motion.div>
))}
</motion.div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
);
};

View File

@ -1,12 +1,20 @@
import { CourseBasicForm } from "./CourseForms/CourseBasicForm"; import { CourseBasicForm } from "./CourseForms/CourseBasicForm";
import { CourseFormProvider } from "./CourseEditorContext"; import { CourseFormProvider } from "./CourseEditorContext";
import CourseEditorLayout from "./CourseEditorLayout"; 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 ( return (
<CourseFormProvider editId={id}> <CourseFormProvider editId={id} part={part}>
<CourseEditorLayout> <CourseEditorLayout>
<CourseBasicForm></CourseBasicForm> <CourseForm></CourseForm>
</CourseEditorLayout> </CourseEditorLayout>
</CourseFormProvider> </CourseFormProvider>
); );

View File

@ -1,108 +1,119 @@
import { createContext, useContext, ReactNode, useEffect } from 'react'; import { createContext, useContext, ReactNode, useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import { CourseDto, CourseLevel, CourseStatus } from '@nice/common'; import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
import { api, useCourse } from '@nice/client'; import { api, useCourse } from "@nice/client";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
// 定义课程表单验证 Schema // 定义课程表单验证 Schema
const courseSchema = z.object({ const courseSchema = z.object({
title: z.string().min(1, '课程标题不能为空'), title: z.string().min(1, "课程标题不能为空"),
subTitle: z.string().nullish(), subTitle: z.string().nullish(),
description: z.string().nullish(), description: z.string().nullish(),
thumbnail: z.string().url().nullish(), thumbnail: z.string().url().nullish(),
level: z.nativeEnum(CourseLevel), level: z.nativeEnum(CourseLevel),
requirements: z.array(z.string()).nullish(), requirements: z.array(z.string()).nullish(),
objectives: z.array(z.string()).nullish(), objectives: z.array(z.string()).nullish(),
skills: z.array(z.string()).nullish(), skills: z.array(z.string()).nullish(),
audiences: z.array(z.string()).nullish(), audiences: z.array(z.string()).nullish(),
status: z.nativeEnum(CourseStatus), status: z.nativeEnum(CourseStatus),
}); });
export type CourseFormData = z.infer<typeof courseSchema>; export type CourseFormData = z.infer<typeof courseSchema>;
interface CourseEditorContextType { interface CourseEditorContextType {
onSubmit: SubmitHandler<CourseFormData>; onSubmit: SubmitHandler<CourseFormData>;
editId?: string; // 添加 editId editId?: string; // 添加 editId
course?: CourseDto part?: string;
course?: CourseDto;
} }
interface CourseFormProviderProps { interface CourseFormProviderProps {
children: ReactNode; children: ReactNode;
editId?: string; // 添加 editId 参数 editId?: string; // 添加 editId 参数
part?: string;
} }
const CourseEditorContext = createContext<CourseEditorContextType | null>(null); const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) { export function CourseFormProvider({
const { create, update } = useCourse() children,
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) }) editId,
const methods = useForm<CourseFormData>({ part,
resolver: zodResolver(courseSchema), }: CourseFormProviderProps) {
defaultValues: { const { create, update } = useCourse();
status: CourseStatus.DRAFT, const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery(
level: CourseLevel.BEGINNER, { where: { id: editId } },
requirements: [], { enabled: Boolean(editId) }
objectives: [], );
skills: [], const methods = useForm<CourseFormData>({
audiences: [], resolver: zodResolver(courseSchema),
}, defaultValues: {
}); status: CourseStatus.DRAFT,
const navigate = useNavigate() level: CourseLevel.BEGINNER,
useEffect(() => { requirements: [],
if (course) { objectives: [],
// 只选择表单需要的字段 skills: [],
const formData = { audiences: [],
title: course.title, },
subTitle: course.subTitle, });
description: course.description, const navigate = useNavigate();
thumbnail: course.thumbnail, useEffect(() => {
level: course.level, if (course) {
requirements: course.requirements, // 只选择表单需要的字段
objectives: course.objectives, const formData = {
skills: course.skills, title: course.title,
audiences: course.audiences, subTitle: course.subTitle,
status: course.status, description: course.description,
}; thumbnail: course.thumbnail,
methods.reset(formData as any); level: course.level,
} requirements: course.requirements,
}, [course, methods]); objectives: course.objectives,
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => { skills: course.skills,
try { audiences: course.audiences,
if (editId) { status: course.status,
await update.mutateAsync({ };
where: { id: editId }, methods.reset(formData as any);
data: { }
...data }, [course, methods]);
} const onSubmit: SubmitHandler<CourseFormData> = async (
}) data: CourseFormData
toast.success('课程更新成功!'); ) => {
} else { try {
const result = await create.mutateAsync({ if (editId) {
data: { await update.mutateAsync({
...data where: { id: editId },
} data: {
}) ...data,
console.log(`/course/${result.id}/manage`) },
navigate(`/course/${result.id}/manage`, { replace: true }) });
toast.success('课程创建成功!'); 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) { return (
console.error('Error submitting form:', error); <CourseEditorContext.Provider
toast.error('操作失败,请重试!'); value={{ onSubmit, editId, course, part }}>
} <FormProvider {...methods}>{children}</FormProvider>
}; </CourseEditorContext.Provider>
);
return (
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
<FormProvider {...methods}>
{children}
</FormProvider>
</CourseEditorContext.Provider>
);
} }
export const useCourseEditor = () => { export const useCourseEditor = () => {
const context = useContext(CourseEditorContext); const context = useContext(CourseEditorContext);
if (!context) { if (!context) {
throw new Error('useCourseEditor must be used within CourseFormProvider'); throw new Error(
} "useCourseEditor must be used within CourseFormProvider"
return context; );
}; }
return context;
};

View File

@ -13,6 +13,7 @@ const courseStatusVariant: Record<CourseStatus, string> = {
}; };
export default function CourseEditorHeader() { export default function CourseEditorHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>() const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
const { onSubmit, course } = useCourseEditor() const { onSubmit, course } = useCourseEditor()
return ( return (

View File

@ -12,6 +12,7 @@ interface CourseEditorLayoutProps {
export default function CourseEditorLayout({ export default function CourseEditorLayout({
children, children,
}: CourseEditorLayoutProps) { }: CourseEditorLayoutProps) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [selectedSection, setSelectedSection] = useState<number>(0); const [selectedSection, setSelectedSection] = useState<number>(0);
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS); const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);

View File

@ -7,6 +7,7 @@ import { FormSelect } from "@web/src/components/presentation/form/FormSelect";
import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField";
import { convertToOptions } from "@nice/client"; import { convertToOptions } from "@nice/client";
import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs"; import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs";
import { useEffect } from "react";
export function CourseBasicForm() { export function CourseBasicForm() {
const { const {
@ -15,7 +16,9 @@ export function CourseBasicForm() {
watch, watch,
handleSubmit, handleSubmit,
} = useFormContext<CourseFormData>(); } = useFormContext<CourseFormData>();
useEffect(() => {
console.log(watch("audiences"));
}, [watch("audiences")]);
return ( return (
<form className="max-w-2xl mx-auto space-y-6 p-6"> <form className="max-w-2xl mx-auto space-y-6 p-6">
<FormInput <FormInput
@ -24,10 +27,6 @@ export function CourseBasicForm() {
label="课程标题" label="课程标题"
placeholder="请输入课程标题" placeholder="请输入课程标题"
/> />
<FormDynamicInputs
name="audiences"
label="目标"></FormDynamicInputs>
<FormInput <FormInput
maxLength={10} maxLength={10}
name="subTitle" name="subTitle"

View File

@ -0,0 +1,49 @@
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";
import { useEffect } from "react";
export function CourseContentForm() {
const {
register,
formState: { errors },
watch,
handleSubmit,
} = useFormContext<CourseFormData>();
useEffect(() => {
console.log(watch("audiences"));
}, [watch("audiences")]);
return (
<form className="max-w-2xl mx-auto space-y-6 p-6">
<FormInput
maxLength={20}
name="title"
label="课程标题"
placeholder="请输入课程标题"
/>
<FormInput
maxLength={10}
name="subTitle"
label="课程副标题"
placeholder="请输入课程副标题"
/>
<FormInput
name="description"
label="课程描述"
type="textarea"
placeholder="请输入课程描述"
/>
<FormSelect
name="level"
label="难度等级"
options={convertToOptions(CourseLevelLabel)}></FormSelect>
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
</form>
);
}

View File

@ -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 <CourseBasicForm></CourseBasicForm>;
}
if (part === CoursePart.TARGET) {
return <CourseTargetForm></CourseTargetForm>;
}
if (part === CoursePart.CONTENT) {
return <CourseContentForm></CourseContentForm>;
}
if (part === CoursePart.SETTING) {
return <></>;
}
return <CourseBasicForm></CourseBasicForm>;
}

View File

@ -6,8 +6,10 @@ import { FormInput } from "@web/src/components/presentation/form/FormInput";
import { FormSelect } from "@web/src/components/presentation/form/FormSelect"; import { FormSelect } from "@web/src/components/presentation/form/FormSelect";
import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField"; import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField";
import { convertToOptions } from "@nice/client"; 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 { const {
register, register,
formState: { errors }, formState: { errors },

View File

@ -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<CourseFormData>();
return (
<form className="max-w-2xl mx-auto space-y-6 p-6">
<FormDynamicInputs
name="objectives"
label="本课的具体学习目标是什么?"
// subTitle="学员在完成您的课程后期望掌握的技能"
addTitle="目标"></FormDynamicInputs>
<FormDynamicInputs
name="skills"
label="学生将从您的课程中学到什么技能?"
subTitle="学员在完成您的课程后期望掌握的技能"
addTitle="技能"></FormDynamicInputs>
<FormDynamicInputs
name="requirements"
label="参加课程的要求或基本要求是什么?"
subTitle="
"
addTitle="要求"></FormDynamicInputs>
<FormDynamicInputs
name="audiences"
subTitle="撰写您的课程目标学员的清晰描述,让学员了解您的课程内容很有价值。这将帮助您吸引合适的学员加入您的课程。"
addTitle="目标受众"
label="此课程的受众是谁?"></FormDynamicInputs>
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
</form>
);
}

View File

@ -0,0 +1,6 @@
export enum CoursePart {
OVERVIEW = "overview",
TARGET = "target",
CONTENT = "content",
SETTING = "settings",
}

View File

@ -5,26 +5,54 @@ import {
VideoCameraIcon, VideoCameraIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { NavItem } from "@nice/client"; 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 })[] = [ return [
{ {
label: "课程概述", label: "课程概述",
icon: <BookOpenIcon className="w-5 h-5" />, icon: <BookOpenIcon className="w-5 h-5" />,
path: "/manage/overview", path: `${basePath}/manage/${CoursePart.OVERVIEW}`,
}, },
{ {
label: "目标学员", label: "目标学员",
icon: <AcademicCapIcon className="w-5 h-5" />, icon: <AcademicCapIcon className="w-5 h-5" />,
path: "/manage/target", path: `${basePath}/manage/${CoursePart.TARGET}`,
}, },
{ {
label: "课程内容", label: "课程内容",
icon: <VideoCameraIcon className="w-5 h-5" />, icon: <VideoCameraIcon className="w-5 h-5" />,
path: "/manage/content", path: `${basePath}/manage/${CoursePart.CONTENT}`,
}, },
{ {
label: "课程设置", label: "课程设置",
icon: <Cog6ToothIcon className="w-5 h-5" />, icon: <Cog6ToothIcon className="w-5 h-5" />,
path: "/manage/settings", path: `${basePath}/manage/${CoursePart.SETTING}`,
}, },
]; ];
};
// export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [
// {
// label: "课程概述",
// icon: <BookOpenIcon className="w-5 h-5" />,
// path: `/course/${}/manage/${CoursePart.OVERVIEW}`,
// },
// {
// label: "目标学员",
// icon: <AcademicCapIcon className="w-5 h-5" />,
// path: `/manage/${CoursePart.TARGET}`,
// },
// {
// label: "课程内容",
// icon: <VideoCameraIcon className="w-5 h-5" />,
// path: `/manage/${CoursePart.CONTENT}`,
// },
// {
// label: "课程设置",
// icon: <Cog6ToothIcon className="w-5 h-5" />,
// path: `/manage/${CoursePart.SETTING}`,
// },
// ];

View File

@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "framer-motion";
import React, { useState } from "react"; import React, { useState } from "react";
import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline";
import FormError from "./FormError"; import FormError from "./FormError";
import { TrashIcon } from "@heroicons/react/24/solid";
export interface DynamicFormInputProps export interface DynamicFormInputProps
extends Omit< extends Omit<
@ -11,6 +12,8 @@ export interface DynamicFormInputProps
"type" "type"
> { > {
name: string; name: string;
addTitle?: string;
subTitle?: string;
label: string; label: string;
type?: type?:
| "text" | "text"
@ -29,7 +32,9 @@ export interface DynamicFormInputProps
export function FormDynamicInputs({ export function FormDynamicInputs({
name, name,
addTitle,
label, label,
subTitle,
type = "text", type = "text",
rows = 4, rows = 4,
className, className,
@ -49,7 +54,14 @@ export function FormDynamicInputs({
control, control,
name, name,
}); });
// 添加 onChange 处理函数
const handleInputChange = (index: number, value: string) => {
setValue(`${name}.${index}`, value, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
};
const handleBlur = async (index: number) => { const handleBlur = async (index: number) => {
setFocusedIndexes(focusedIndexes.filter((i) => i !== index)); setFocusedIndexes(focusedIndexes.filter((i) => i !== index));
await trigger(`${name}.${index}`); await trigger(`${name}.${index}`);
@ -65,12 +77,16 @@ export function FormDynamicInputs({
`; `;
const InputElement = type === "textarea" ? "textarea" : "input"; const InputElement = type === "textarea" ? "textarea" : "input";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{label} {label}
</label> </label>
{subTitle && (
<label className="block text-sm font-normal text-gray-500">
{subTitle}
</label>
)}
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{fields.map((field, index) => ( {fields.map((field, index) => (
@ -83,7 +99,13 @@ export function FormDynamicInputs({
className="group relative"> className="group relative">
<div className="relative"> <div className="relative">
<InputElement <InputElement
{...register(`${name}.${index}`)} {...register(`${name}.${index}`, {
onChange: (e) =>
handleInputChange(
index,
e.target.value
),
})}
type={type !== "textarea" ? type : undefined} type={type !== "textarea" ? type : undefined}
rows={type === "textarea" ? rows : undefined} rows={type === "textarea" ? rows : undefined}
{...restProps} {...restProps}
@ -97,40 +119,20 @@ export function FormDynamicInputs({
className={inputClasses} className={inputClasses}
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1"> {/* 修改这部分,将删除按钮放在 input 内部右侧 */}
{values[index] && <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-2">
focusedIndexes.includes(index) && (
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
onMouseDown={(e) =>
e.preventDefault()
}
onClick={() =>
setValue(`${name}.${index}`, "")
}>
<XMarkIcon className="w-4 h-4" />
</button>
)}
{values[index] && !fieldErrors?.[index] && ( {values[index] && !fieldErrors?.[index] && (
<CheckIcon className="text-green-500 w-4 h-4" /> <CheckIcon className="text-green-500 w-4 h-4" />
)} )}
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="p-1 text-red-500 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<TrashIcon className="w-4 h-4" />
</button>
)}
</div> </div>
{index > 0 && (
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => 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">
<XMarkIcon className="w-4 h-4" />
</motion.button>
)}
</div> </div>
{fieldErrors?.[index]?.message && ( {fieldErrors?.[index]?.message && (
<FormError error={fieldErrors[index].message} /> <FormError error={fieldErrors[index].message} />
@ -147,7 +149,7 @@ export function FormDynamicInputs({
className="flex items-center gap-1 text-blue-500 hover:text-blue-600 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"> transition-colors px-4 py-2 rounded-lg hover:bg-blue-50">
<PlusIcon className="w-5 h-5" /> <PlusIcon className="w-5 h-5" />
{label} {addTitle || label}
</motion.button> </motion.button>
</div> </div>
); );

View File

@ -84,22 +84,8 @@ export const routes: CustomRouteObject[] = [
path: "course", path: "course",
children: [ children: [
{ {
path: ":id?/manage", // 使用 ? 表示 id 参数是可选的 path: ":id?/manage/:part?", // 使用 ? 表示 id 参数是可选的
element: <CourseEditorPage />, element: <CourseEditorPage />,
children: [
{
index: true, // This will make :id?/manage the default route
element: <CourseEditorPage />,
},
{
path: "overview",
element: <CourseEditorPage />, // You might want to create a specific overview component
},
{
path: "target",
element: <CourseEditorPage />, // Create a specific target page component
},
],
}, },
{ {
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的 path: ":id?/detail", // 使用 ? 表示 id 参数是可选的