add
This commit is contained in:
parent
4ac8c07215
commit
b1c27943cb
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
|
@ -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 },
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export enum CoursePart {
|
||||||
|
OVERVIEW = "overview",
|
||||||
|
TARGET = "target",
|
||||||
|
CONTENT = "content",
|
||||||
|
SETTING = "settings",
|
||||||
|
}
|
|
@ -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}`,
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 参数是可选的
|
||||||
|
|
Loading…
Reference in New Issue