diff --git a/apps/server/src/queue/postprocess/postprocess.service.ts b/apps/server/src/queue/postprocess/postprocess.service.ts index ddbc821..eef571b 100644 --- a/apps/server/src/queue/postprocess/postprocess.service.ts +++ b/apps/server/src/queue/postprocess/postprocess.service.ts @@ -1,28 +1,24 @@ -import { InjectQueue } from "@nestjs/bullmq"; -import { Injectable } from "@nestjs/common"; -import EventBus from "@server/utils/event-bus"; -import { Queue } from "bullmq"; -import { ObjectType } from "@nice/common"; -import { QueueJobType } from "../types"; +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import EventBus from '@server/utils/event-bus'; +import { Queue } from 'bullmq'; +import { ObjectType } from '@nice/common'; +import { QueueJobType } from '../types'; @Injectable() export class PostProcessService { - constructor( - @InjectQueue('general') private generalQueue: Queue - ) { - - } + constructor(@InjectQueue('general') private generalQueue: Queue) {} - private generateJobId(type: ObjectType, data: any): string { - // 根据类型和相关ID生成唯一的job标识 - switch (type) { - case ObjectType.ENROLLMENT: - return `stats_${type}_${data.courseId}`; - case ObjectType.LECTURE: - return `stats_${type}_${data.courseId}_${data.sectionId}`; - case ObjectType.POST: - return `stats_${type}_${data.courseId}`; - default: - return `stats_${type}_${Date.now()}`; - } + private generateJobId(type: ObjectType, data: any): string { + // 根据类型和相关ID生成唯一的job标识 + switch (type) { + case ObjectType.ENROLLMENT: + return `stats_${type}_${data.courseId}`; + case ObjectType.LECTURE: + return `stats_${type}_${data.courseId}_${data.sectionId}`; + case ObjectType.POST: + return `stats_${type}_${data.courseId}`; + default: + return `stats_${type}_${Date.now()}`; } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/stats/stats.service.ts b/apps/server/src/queue/stats/stats.service.ts index a498704..e8ce2dc 100644 --- a/apps/server/src/queue/stats/stats.service.ts +++ b/apps/server/src/queue/stats/stats.service.ts @@ -1,70 +1,68 @@ -import { InjectQueue } from "@nestjs/bullmq"; -import { Injectable } from "@nestjs/common"; -import EventBus from "@server/utils/event-bus"; -import { Queue } from "bullmq"; -import { ObjectType } from "@nice/common"; -import { QueueJobType } from "../types"; +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import EventBus from '@server/utils/event-bus'; +import { Queue } from 'bullmq'; +import { ObjectType } from '@nice/common'; +import { QueueJobType } from '../types'; @Injectable() export class StatsService { - constructor( - @InjectQueue('general') private generalQueue: Queue - ) { - EventBus.on("dataChanged", async ({ type, data }) => { - const jobOptions = { - removeOnComplete: true, - jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务 - }; - switch (type) { - case ObjectType.ENROLLMENT: - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - courseId: data.courseId, - type: ObjectType.ENROLLMENT - }, - jobOptions - ); - break; + constructor(@InjectQueue('general') private generalQueue: Queue) { + EventBus.on('dataChanged', async ({ type, data }) => { + const jobOptions = { + removeOnComplete: true, + jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务 + }; + switch (type) { + case ObjectType.ENROLLMENT: + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + courseId: data.courseId, + type: ObjectType.ENROLLMENT, + }, + jobOptions, + ); + break; - case ObjectType.LECTURE: - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - sectionId: data.sectionId, - courseId: data.courseId, - type: ObjectType.LECTURE - }, - jobOptions - ); - break; + case ObjectType.LECTURE: + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + sectionId: data.sectionId, + courseId: data.courseId, + type: ObjectType.LECTURE, + }, + jobOptions, + ); + break; - case ObjectType.POST: - if (data.courseId) { - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - courseId: data.courseId, - type: ObjectType.POST - }, - jobOptions - ); - } - break; - } - }); + case ObjectType.POST: + if (data.courseId) { + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + courseId: data.courseId, + type: ObjectType.POST, + }, + jobOptions, + ); + } + break; + } + }); + } + + private generateJobId(type: ObjectType, data: any): string { + // 根据类型和相关ID生成唯一的job标识 + switch (type) { + case ObjectType.ENROLLMENT: + return `stats_${type}_${data.courseId}`; + case ObjectType.LECTURE: + return `stats_${type}_${data.courseId}_${data.sectionId}`; + case ObjectType.POST: + return `stats_${type}_${data.courseId}`; + default: + return `stats_${type}_${Date.now()}`; } - - private generateJobId(type: ObjectType, data: any): string { - // 根据类型和相关ID生成唯一的job标识 - switch (type) { - case ObjectType.ENROLLMENT: - return `stats_${type}_${data.courseId}`; - case ObjectType.LECTURE: - return `stats_${type}_${data.courseId}_${data.sectionId}`; - case ObjectType.POST: - return `stats_${type}_${data.courseId}`; - default: - return `stats_${type}_${Date.now()}`; - } - } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts index de3d836..b416af4 100644 --- a/apps/server/src/queue/worker/file.processor.ts +++ b/apps/server/src/queue/worker/file.processor.ts @@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor'; const logger = new Logger('FileProcessorWorker'); const pipeline = new ResourceProcessingPipeline() - .addProcessor(new ImageProcessor()) - .addProcessor(new VideoProcessor()) + .addProcessor(new ImageProcessor()) + .addProcessor(new VideoProcessor()); export default async function processJob(job: Job) { - if (job.name === QueueJobType.FILE_PROCESS) { - console.log(job) - const { resource } = job.data; - if (!resource) { - throw new Error('No resource provided in job data'); - } - const result = await pipeline.execute(resource); - - return result; + if (job.name === QueueJobType.FILE_PROCESS) { + console.log('job', job); + const { resource } = job.data; + if (!resource) { + throw new Error('No resource provided in job data'); } -} \ No newline at end of file + const result = await pipeline.execute(resource); + + return result; + } +} diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts index 6574912..e5b2052 100755 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -1,49 +1,52 @@ import { Job } from 'bullmq'; import { Logger } from '@nestjs/common'; import { - updateCourseLectureStats, - updateSectionLectureStats + updateCourseLectureStats, + updateSectionLectureStats, } from '@server/models/lecture/utils'; import { ObjectType } from '@nice/common'; import { - updateCourseEnrollmentStats, - updateCourseReviewStats + updateCourseEnrollmentStats, + updateCourseReviewStats, } from '@server/models/course/utils'; import { QueueJobType } from '../types'; const logger = new Logger('QueueWorker'); export default async function processJob(job: Job) { - try { - if (job.name === QueueJobType.UPDATE_STATS) { - const { sectionId, courseId, type } = job.data; - // 处理 section 统计 - if (sectionId) { - await updateSectionLectureStats(sectionId); - logger.debug(`Updated section stats for sectionId: ${sectionId}`); - } - // 如果没有 courseId,提前返回 - if (!courseId) { - return; - } - // 处理 course 相关统计 - switch (type) { - case ObjectType.LECTURE: - await updateCourseLectureStats(courseId); - break; - case ObjectType.ENROLLMENT: - await updateCourseEnrollmentStats(courseId); - break; - case ObjectType.POST: - await updateCourseReviewStats(courseId); - break; - default: - logger.warn(`Unknown update stats type: ${type}`); - } + try { + if (job.name === QueueJobType.UPDATE_STATS) { + const { sectionId, courseId, type } = job.data; + // 处理 section 统计 + if (sectionId) { + await updateSectionLectureStats(sectionId); + logger.debug(`Updated section stats for sectionId: ${sectionId}`); + } + // 如果没有 courseId,提前返回 + if (!courseId) { + return; + } + // 处理 course 相关统计 + switch (type) { + case ObjectType.LECTURE: + await updateCourseLectureStats(courseId); + break; + case ObjectType.ENROLLMENT: + await updateCourseEnrollmentStats(courseId); + break; + case ObjectType.POST: + await updateCourseReviewStats(courseId); + break; + default: + logger.warn(`Unknown update stats type: ${type}`); + } - logger.debug(`Updated course stats for courseId: ${courseId}, type: ${type}`); - } - - - } catch (error: any) { - logger.error(`Error processing stats update job: ${error.message}`, error.stack); + logger.debug( + `Updated course stats for courseId: ${courseId}, type: ${type}`, + ); } -} \ No newline at end of file + } catch (error: any) { + logger.error( + `Error processing stats update job: ${error.message}`, + error.stack, + ); + } +} diff --git a/apps/server/src/tasks/init/init.service.ts b/apps/server/src/tasks/init/init.service.ts index 4f68e8e..8d6a955 100755 --- a/apps/server/src/tasks/init/init.service.ts +++ b/apps/server/src/tasks/init/init.service.ts @@ -19,7 +19,7 @@ export class InitService { private readonly minioService: MinioService, private readonly authService: AuthService, private readonly genDevService: GenDevService, - ) { } + ) {} private async createRoles() { this.logger.log('Checking existing system roles'); for (const role of InitRoles) { diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 0eea246..0173d25 100644 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; -import { Server, Uid, Upload } from "@nice/tus" +import { Server, Uid, Upload } from '@nice/tus'; import { FileStore } from '@nice/tus'; -import { Request, Response } from "express" +import { Request, Response } from 'express'; import { db, ResourceStatus } from '@nice/common'; import { getFilenameWithoutExt } from '@server/utils/file'; import { ResourceService } from '@server/models/resource/resource.service'; @@ -12,104 +12,122 @@ import { QueueJobType } from '@server/queue/types'; import { nanoid } from 'nanoid-cjs'; import { slugify } from 'transliteration'; const FILE_UPLOAD_CONFIG = { - directory: process.env.UPLOAD_DIR, - maxSizeBytes: 20_000_000_000, // 20GB - expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours + directory: process.env.UPLOAD_DIR, + maxSizeBytes: 20_000_000_000, // 20GB + expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours }; @Injectable() export class TusService implements OnModuleInit { - private readonly logger = new Logger(TusService.name); - private tusServer: Server; - constructor(private readonly resourceService: ResourceService, - @InjectQueue("file-queue") private fileQueue: Queue - ) { } - onModuleInit() { - this.initializeTusServer(); - this.setupTusEventHandlers(); - } - private initializeTusServer() { - this.tusServer = new Server({ - namingFunction(req, metadata) { - const safeFilename = slugify(metadata.filename); - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const uniqueId = nanoid(10); - return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; - }, - path: '/upload', - datastore: new FileStore({ - directory: FILE_UPLOAD_CONFIG.directory, - expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod - }), - maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, - postReceiveInterval: 1000, - getFileIdFromRequest: (req, lastPath) => { - const match = req.url.match(/\/upload\/(.+)/); - return match ? match[1] : lastPath; - } - }); - } + private readonly logger = new Logger(TusService.name); + private tusServer: Server; + constructor( + private readonly resourceService: ResourceService, + @InjectQueue('file-queue') private fileQueue: Queue, + ) {} + onModuleInit() { + this.initializeTusServer(); + this.setupTusEventHandlers(); + } + private initializeTusServer() { + this.tusServer = new Server({ + namingFunction(req, metadata) { + const safeFilename = slugify(metadata.filename); + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const uniqueId = nanoid(10); + return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; + }, + path: '/upload', + datastore: new FileStore({ + directory: FILE_UPLOAD_CONFIG.directory, + expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod, + }), + maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, + postReceiveInterval: 1000, + getFileIdFromRequest: (req, lastPath) => { + const match = req.url.match(/\/upload\/(.+)/); + return match ? match[1] : lastPath; + }, + }); + } - private setupTusEventHandlers() { - this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this)); - this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this)); + private setupTusEventHandlers() { + this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this)); + this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this)); + } + private getFileId(uploadId: string) { + return uploadId.replace(/\/[^/]+$/, ''); + } + private async handleUploadCreate( + req: Request, + res: Response, + upload: Upload, + url: string, + ) { + try { + const fileId = this.getFileId(upload.id); + // const filename = upload.metadata.filename; + await this.resourceService.create({ + data: { + title: getFilenameWithoutExt(upload.metadata.filename), + fileId, // 移除最后的文件名 + url: upload.id, + metadata: upload.metadata, + status: ResourceStatus.UPLOADING, + }, + }); + } catch (error) { + this.logger.error('Failed to create resource during upload', error); } - private getFileId(uploadId: string) { - return uploadId.replace(/\/[^/]+$/, '') - } - private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) { - try { + } - const fileId = this.getFileId(upload.id) - const filename = upload.metadata.filename - await this.resourceService.create({ - data: { - title: getFilenameWithoutExt(upload.metadata.filename), - fileId, // 移除最后的文件名 - url: upload.id, - metadata: upload.metadata, - status: ResourceStatus.UPLOADING - } - }); - } catch (error) { - this.logger.error('Failed to create resource during upload', error); - } + private async handleUploadFinish( + req: Request, + res: Response, + upload: Upload, + ) { + try { + console.log('upload.id', upload.id); + console.log('fileId', this.getFileId(upload.id)); + const resource = await this.resourceService.update({ + where: { fileId: this.getFileId(upload.id) }, + data: { status: ResourceStatus.UPLOADED }, + }); + this.fileQueue.add( + QueueJobType.FILE_PROCESS, + { resource }, + { jobId: resource.id }, + ); + this.logger.log(`Upload finished ${resource.url}`); + } catch (error) { + this.logger.error('Failed to update resource after upload', error); } + } - private async handleUploadFinish(req: Request, res: Response, upload: Upload) { - try { - const resource = await this.resourceService.update({ - where: { fileId: this.getFileId(upload.id) }, - data: { status: ResourceStatus.UPLOADED } - }); - this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id }) - this.logger.log(`Upload finished ${resource.url}`); - } catch (error) { - this.logger.error('Failed to update resource after upload', error); - } + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async cleanupExpiredUploads() { + try { + // Delete incomplete uploads older than 24 hours + const deletedResources = await db.resource.deleteMany({ + where: { + createdAt: { + lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod), + }, + status: ResourceStatus.UPLOADING, + }, + }); + const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); + this.logger.log( + `Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`, + ); + } catch (error) { + this.logger.error('Expired uploads cleanup failed', error); } + } - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async cleanupExpiredUploads() { - try { - // Delete incomplete uploads older than 24 hours - const deletedResources = await db.resource.deleteMany({ - where: { - createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) }, - status: ResourceStatus.UPLOADING - } - }); - const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); - this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`); - } catch (error) { - this.logger.error('Expired uploads cleanup failed', error); - } - } - - async handleTus(req: Request, res: Response) { - - return this.tusServer.handle(req, res); - } -} \ No newline at end of file + async handleTus(req: Request, res: Response) { + return this.tusServer.handle(req, res); + } +} diff --git a/apps/server/src/upload/types.ts b/apps/server/src/upload/types.ts index ef60aaa..2140ebc 100644 --- a/apps/server/src/upload/types.ts +++ b/apps/server/src/upload/types.ts @@ -1,19 +1,24 @@ export interface UploadCompleteEvent { - identifier: string; - filename: string; - size: number; - hash: string; - integrityVerified: boolean; + identifier: string; + filename: string; + size: number; + hash: string; + integrityVerified: boolean; } export type UploadEvent = { - uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean }; - uploadComplete: UploadCompleteEvent - uploadError: { identifier: string; error: string, filename: string }; -} + uploadStart: { + identifier: string; + filename: string; + totalSize: number; + resuming?: boolean; + }; + uploadComplete: UploadCompleteEvent; + uploadError: { identifier: string; error: string; filename: string }; +}; export interface UploadLock { - clientId: string; - timestamp: number; + clientId: string; + timestamp: number; } // 添加重试机制,处理临时网络问题 // 实现定期清理过期的临时文件 @@ -21,4 +26,4 @@ export interface UploadLock { // 实现上传进度持久化,支持服务重启后恢复 // 添加并发限制,防止系统资源耗尽 // 实现文件去重功能,避免重复上传 -// 添加日志记录和监控机制 \ No newline at end of file +// 添加日志记录和监控机制 diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts index ff3e38b..f014c42 100644 --- a/apps/server/src/upload/upload.controller.ts +++ b/apps/server/src/upload/upload.controller.ts @@ -1,55 +1,54 @@ import { - Controller, - All, - Req, - Res, - Get, - Post, - Patch, - Param, - Delete, - Head, - Options, + Controller, + All, + Req, + Res, + Get, + Post, + Patch, + Param, + Delete, + Head, + Options, } from '@nestjs/common'; -import { Request, Response } from "express" +import { Request, Response } from 'express'; import { TusService } from './tus.service'; @Controller('upload') export class UploadController { - constructor(private readonly tusService: TusService) { } - // @Post() - // async handlePost(@Req() req: Request, @Res() res: Response) { - // return this.tusService.handleTus(req, res); - // } + constructor(private readonly tusService: TusService) {} + // @Post() + // async handlePost(@Req() req: Request, @Res() res: Response) { + // return this.tusService.handleTus(req, res); + // } + @Options() + async handleOptions(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Options() - async handleOptions(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Head() + async handleHead(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Head() - async handleHead(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Post() + async handlePost(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + @Get('/*') + async handleGet(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Post() - async handlePost(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } - @Get("/*") - async handleGet(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Patch('/*') + async handlePatch(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Patch("/*") - async handlePatch(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } - - // Keeping the catch-all method as a fallback - @All() - async handleUpload(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } -} \ No newline at end of file + // Keeping the catch-all method as a fallback + @All() + async handleUpload(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } +} diff --git a/apps/server/src/upload/upload.module.ts b/apps/server/src/upload/upload.module.ts index 0a54ccb..6c8e1b0 100644 --- a/apps/server/src/upload/upload.module.ts +++ b/apps/server/src/upload/upload.module.ts @@ -5,13 +5,13 @@ import { TusService } from './tus.service'; import { ResourceModule } from '@server/models/resource/resource.module'; @Module({ - imports: [ - BullModule.registerQueue({ - name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致 - }), - ResourceModule - ], - controllers: [UploadController], - providers: [TusService], + imports: [ + BullModule.registerQueue({ + name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致 + }), + ResourceModule, + ], + controllers: [UploadController], + providers: [TusService], }) -export class UploadModule { } \ No newline at end of file +export class UploadModule {} diff --git a/apps/server/src/upload/utils.ts b/apps/server/src/upload/utils.ts index a171d7f..a7c189f 100644 --- a/apps/server/src/upload/utils.ts +++ b/apps/server/src/upload/utils.ts @@ -1,4 +1,4 @@ export function extractFileIdFromNginxUrl(url: string) { const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/); return match ? match[1] : ''; -} \ No newline at end of file +} diff --git a/apps/server/src/utils/file.ts b/apps/server/src/utils/file.ts index 776d2ec..c24d5d9 100644 --- a/apps/server/src/utils/file.ts +++ b/apps/server/src/utils/file.ts @@ -1,11 +1,10 @@ - import { createHash } from 'crypto'; import { createReadStream } from 'fs'; import path from 'path'; import * as dotenv from 'dotenv'; dotenv.config(); export function getFilenameWithoutExt(filename: string) { - return filename ? filename.replace(/\.[^/.]+$/, '') : filename; + return filename ? filename.replace(/\.[^/.]+$/, '') : filename; } /** * 计算文件的 SHA-256 哈希值 @@ -13,31 +12,31 @@ export function getFilenameWithoutExt(filename: string) { * @returns Promise 返回文件的哈希值(十六进制字符串) */ export async function calculateFileHash(filePath: string): Promise { - return new Promise((resolve, reject) => { - // 创建一个 SHA-256 哈希对象 - const hash = createHash('sha256'); - // 创建文件读取流 - const readStream = createReadStream(filePath); - // 处理读取错误 - readStream.on('error', (error) => { - reject(new Error(`Failed to read file: ${error.message}`)); - }); - // 处理哈希计算错误 - hash.on('error', (error) => { - reject(new Error(`Failed to calculate hash: ${error.message}`)); - }); - // 流式处理文件内容 - readStream - .pipe(hash) - .on('finish', () => { - // 获取最终的哈希值(十六进制格式) - const fileHash = hash.digest('hex'); - resolve(fileHash); - }) - .on('error', (error) => { - reject(new Error(`Hash calculation failed: ${error.message}`)); - }); + return new Promise((resolve, reject) => { + // 创建一个 SHA-256 哈希对象 + const hash = createHash('sha256'); + // 创建文件读取流 + const readStream = createReadStream(filePath); + // 处理读取错误 + readStream.on('error', (error) => { + reject(new Error(`Failed to read file: ${error.message}`)); }); + // 处理哈希计算错误 + hash.on('error', (error) => { + reject(new Error(`Failed to calculate hash: ${error.message}`)); + }); + // 流式处理文件内容 + readStream + .pipe(hash) + .on('finish', () => { + // 获取最终的哈希值(十六进制格式) + const fileHash = hash.digest('hex'); + resolve(fileHash); + }) + .on('error', (error) => { + reject(new Error(`Hash calculation failed: ${error.message}`)); + }); + }); } /** @@ -46,9 +45,9 @@ export async function calculateFileHash(filePath: string): Promise { * @returns string 返回 Buffer 的哈希值(十六进制字符串) */ export function calculateBufferHash(buffer: Buffer): string { - const hash = createHash('sha256'); - hash.update(buffer); - return hash.digest('hex'); + const hash = createHash('sha256'); + hash.update(buffer); + return hash.digest('hex'); } /** @@ -57,11 +56,11 @@ export function calculateBufferHash(buffer: Buffer): string { * @returns string 返回字符串的哈希值(十六进制字符串) */ export function calculateStringHash(content: string): string { - const hash = createHash('sha256'); - hash.update(content); - return hash.digest('hex'); + const hash = createHash('sha256'); + hash.update(content); + return hash.digest('hex'); } export const getUploadFilePath = (fileId: string): string => { - const uploadDirectory = process.env.UPLOAD_DIR; - return path.join(uploadDirectory, fileId); -}; \ No newline at end of file + const uploadDirectory = process.env.UPLOAD_DIR; + return path.join(uploadDirectory, fileId); +}; diff --git a/apps/server/src/utils/minio/minio.service.ts b/apps/server/src/utils/minio/minio.service.ts index 8f402ee..e949bf6 100644 --- a/apps/server/src/utils/minio/minio.service.ts +++ b/apps/server/src/utils/minio/minio.service.ts @@ -3,24 +3,24 @@ import * as Minio from 'minio'; @Injectable() export class MinioService { - private readonly logger = new Logger(MinioService.name) - private readonly minioClient: Minio.Client; - constructor() { - this.minioClient = new Minio.Client({ - endPoint: process.env.MINIO_HOST || 'localhost', - port: parseInt(process.env.MINIO_PORT || '9000'), - useSSL: false, - accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', - secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin' - }); - } - async createBucket(bucketName: string): Promise { - const exists = await this.minioClient.bucketExists(bucketName); - if (!exists) { - await this.minioClient.makeBucket(bucketName, ''); - this.logger.log(`Bucket ${bucketName} created successfully.`); - } else { - this.logger.log(`Bucket ${bucketName} already exists.`); - } + private readonly logger = new Logger(MinioService.name); + private readonly minioClient: Minio.Client; + constructor() { + this.minioClient = new Minio.Client({ + endPoint: process.env.MINIO_HOST || 'localhost', + port: parseInt(process.env.MINIO_PORT || '9000'), + useSSL: false, + accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', + secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', + }); + } + async createBucket(bucketName: string): Promise { + const exists = await this.minioClient.bucketExists(bucketName); + if (!exists) { + await this.minioClient.makeBucket(bucketName, ''); + this.logger.log(`Bucket ${bucketName} created successfully.`); + } else { + this.logger.log(`Bucket ${bucketName} already exists.`); } + } } diff --git a/apps/web/package.json b/apps/web/package.json index a7fa5de..3370827 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,92 +1,93 @@ { - "name": "web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@ag-grid-community/client-side-row-model": "~32.3.2", - "@ag-grid-community/core": "~32.3.2", - "@ag-grid-community/react": "~32.3.2", - "@ag-grid-enterprise/clipboard": "~32.3.2", - "@ag-grid-enterprise/column-tool-panel": "~32.3.2", - "@ag-grid-enterprise/core": "~32.3.2", - "@ag-grid-enterprise/filter-tool-panel": "~32.3.2", - "@ag-grid-enterprise/master-detail": "~32.3.2", - "@ag-grid-enterprise/menu": "~32.3.2", - "@ag-grid-enterprise/range-selection": "~32.3.2", - "@ag-grid-enterprise/server-side-row-model": "~32.3.2", - "@ag-grid-enterprise/set-filter": "~32.3.2", - "@ag-grid-enterprise/status-bar": "~32.3.2", - "@ant-design/icons": "^5.4.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@floating-ui/react": "^0.26.25", - "@heroicons/react": "^2.2.0", - "@hookform/resolvers": "^3.9.1", - "@nice/client": "workspace:^", - "@nice/common": "workspace:^", - "@nice/iconer": "workspace:^", - "@nice/mindmap": "workspace:^", - "@nice/ui": "workspace:^", - "@tanstack/query-async-storage-persister": "^5.51.9", - "@tanstack/react-query": "^5.51.21", - "@tanstack/react-query-persist-client": "^5.51.9", - "@trpc/client": "11.0.0-rc.456", - "@trpc/react-query": "11.0.0-rc.456", - "@trpc/server": "11.0.0-rc.456", - "@xyflow/react": "^12.3.6", - "ag-grid-community": "~32.3.2", - "ag-grid-enterprise": "~32.3.2", - "ag-grid-react": "~32.3.2", - "antd": "^5.19.3", - "axios": "^1.7.2", - "browser-image-compression": "^2.0.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "d3-dag": "^1.1.0", - "d3-hierarchy": "^3.1.2", - "dayjs": "^1.11.12", - "elkjs": "^0.9.3", - "framer-motion": "^11.15.0", - "hls.js": "^1.5.18", - "idb-keyval": "^6.2.1", - "mitt": "^3.0.1", - "quill": "2.0.3", - "react": "18.2.0", - "react-beautiful-dnd": "^13.1.1", - "react-dom": "18.2.0", - "react-dropzone": "^14.3.5", - "react-hook-form": "^7.54.2", - "react-hot-toast": "^2.4.1", - "react-resizable": "^3.0.5", - "react-router-dom": "^6.24.1", - "superjson": "^2.2.1", - "tailwind-merge": "^2.6.0", - "uuid": "^10.0.0", - "yjs": "^13.6.20", - "zod": "^3.23.8" - }, - "devDependencies": { - "@eslint/js": "^9.9.0", - "@types/react": "18.2.38", - "@types/react-dom": "18.2.15", - "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.20", - "eslint": "^9.9.0", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.9", - "globals": "^15.9.0", - "postcss": "^8.4.41", - "tailwindcss": "^3.4.10", - "typescript": "^5.5.4", - "typescript-eslint": "^8.0.1", - "vite": "^5.4.1" - } -} \ No newline at end of file + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ag-grid-community/client-side-row-model": "~32.3.2", + "@ag-grid-community/core": "~32.3.2", + "@ag-grid-community/react": "~32.3.2", + "@ag-grid-enterprise/clipboard": "~32.3.2", + "@ag-grid-enterprise/column-tool-panel": "~32.3.2", + "@ag-grid-enterprise/core": "~32.3.2", + "@ag-grid-enterprise/filter-tool-panel": "~32.3.2", + "@ag-grid-enterprise/master-detail": "~32.3.2", + "@ag-grid-enterprise/menu": "~32.3.2", + "@ag-grid-enterprise/range-selection": "~32.3.2", + "@ag-grid-enterprise/server-side-row-model": "~32.3.2", + "@ag-grid-enterprise/set-filter": "~32.3.2", + "@ag-grid-enterprise/status-bar": "~32.3.2", + "@ant-design/icons": "^5.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.26.25", + "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^3.9.1", + "@nice/client": "workspace:^", + "@nice/common": "workspace:^", + "@nice/iconer": "workspace:^", + "@nice/mindmap": "workspace:^", + "@nice/ui": "workspace:^", + "@nice/utils": "workspace:^", + "@tanstack/query-async-storage-persister": "^5.51.9", + "@tanstack/react-query": "^5.51.21", + "@tanstack/react-query-persist-client": "^5.51.9", + "@trpc/client": "11.0.0-rc.456", + "@trpc/react-query": "11.0.0-rc.456", + "@trpc/server": "11.0.0-rc.456", + "@xyflow/react": "^12.3.6", + "ag-grid-community": "~32.3.2", + "ag-grid-enterprise": "~32.3.2", + "ag-grid-react": "~32.3.2", + "antd": "^5.19.3", + "axios": "^1.7.2", + "browser-image-compression": "^2.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3-dag": "^1.1.0", + "d3-hierarchy": "^3.1.2", + "dayjs": "^1.11.12", + "elkjs": "^0.9.3", + "framer-motion": "^11.15.0", + "hls.js": "^1.5.18", + "idb-keyval": "^6.2.1", + "mitt": "^3.0.1", + "quill": "2.0.3", + "react": "18.2.0", + "react-beautiful-dnd": "^13.1.1", + "react-dom": "18.2.0", + "react-dropzone": "^14.3.5", + "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.4.1", + "react-resizable": "^3.0.5", + "react-router-dom": "^6.24.1", + "superjson": "^2.2.1", + "tailwind-merge": "^2.6.0", + "uuid": "^10.0.0", + "yjs": "^13.6.20", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/react": "18.2.38", + "@types/react-dom": "18.2.15", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/apps/web/src/app/main/course/detail/page.tsx b/apps/web/src/app/main/course/detail/page.tsx index d4b385a..d1bf172 100644 --- a/apps/web/src/app/main/course/detail/page.tsx +++ b/apps/web/src/app/main/course/detail/page.tsx @@ -1,5 +1,4 @@ import CourseDetail from "@web/src/components/models/course/detail/CourseDetail"; -import CourseEditor from "@web/src/components/models/course/manage/CourseEditor"; import { useParams } from "react-router-dom"; export function CourseDetailPage() { diff --git a/apps/web/src/app/main/courses/instructor/page.tsx b/apps/web/src/app/main/courses/instructor/page.tsx index 92920de..032409a 100644 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ b/apps/web/src/app/main/courses/instructor/page.tsx @@ -52,7 +52,7 @@ export default function InstructorCoursesPage() { renderItem={(course) => ( { - navigate(`/course/${course.id}/manage`, { + navigate(`/course/${course.id}/editor`, { replace: true, }); }} diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx index fa47a49..b749462 100644 --- a/apps/web/src/app/main/home/page.tsx +++ b/apps/web/src/app/main/home/page.tsx @@ -1,84 +1,25 @@ -import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor'; -import MindMapEditor from '@web/src/components/presentation/mind-map'; -import React, { useState, useCallback } from 'react'; -import * as tus from 'tus-js-client'; +import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor"; +import FileUploader from "@web/src/components/common/uploader/FileUploader"; + +import React, { useState, useCallback } from "react"; +import * as tus from "tus-js-client"; interface TusUploadProps { - onSuccess?: (response: any) => void; - onError?: (error: Error) => void; + onSuccess?: (response: any) => void; + onError?: (error: Error) => void; } -const TusUploader: React.FC = ({ - onSuccess, - onError -}) => { - const [progress, setProgress] = useState(0); - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const handleFileUpload = useCallback((file: File) => { - if (!file) return; - setIsUploading(true); - setProgress(0); - setUploadError(null); - // Extract file extension - const extension = file.name.split('.').pop() || ''; - const upload = new tus.Upload(file, { - endpoint: "http://localhost:3000/upload", - retryDelays: [0, 1000, 3000, 5000], - metadata: { - filename: file.name, - size: file.size.toString(), - mimeType: file.type, - extension: extension, - modifiedAt: new Date(file.lastModified).toISOString(), - }, - onProgress: (bytesUploaded, bytesTotal) => { - const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); - setProgress(Number(percentage)); - }, - onSuccess: () => { - setIsUploading(false); - setProgress(100); - onSuccess && onSuccess(upload); - }, - onError: (error) => { - setIsUploading(false); - setUploadError(error.message); - onError && onError(error); - } - }); - - upload.start(); - }, [onSuccess, onError]); - - return ( -
-
- -
- {/*
+const HomePage: React.FC = ({ onSuccess, onError }) => { + return ( +
+ +
+ +
+ {/*
*/} - {/* */} - - { - const file = e.target.files?.[0]; - if (file) handleFileUpload(file); - }} - /> - {isUploading && ( -
- - {progress}% -
- )} - {uploadError && ( -
- 上传错误: {uploadError} -
- )} -
- ); + {/* */} +
+ ); }; -export default TusUploader; \ No newline at end of file +export default HomePage; diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx index 0d3c62c..3635063 100644 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ b/apps/web/src/components/common/uploader/FileUploader.tsx @@ -1,211 +1,237 @@ -import { useState, useCallback, useRef, memo } from 'react' -import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline' -import * as tus from 'tus-js-client' -import { motion, AnimatePresence } from 'framer-motion' -import { toast } from 'react-hot-toast' +// FileUploader.tsx +import React, { useRef, memo, useState } from "react"; +import { + CloudArrowUpIcon, + XMarkIcon, + DocumentIcon, + ExclamationCircleIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "react-hot-toast"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; + interface FileUploaderProps { - endpoint?: string - onSuccess?: (url: string) => void - onError?: (error: Error) => void - maxSize?: number - allowedTypes?: string[] - placeholder?: string + endpoint?: string; + onSuccess?: (url: string) => void; + onError?: (error: Error) => void; + maxSize?: number; + allowedTypes?: string[]; + placeholder?: string; } -const FileItem = memo(({ file, progress, onRemove }: { - file: File - progress?: number - onRemove: (name: string) => void -}) => ( - - -
-
-

{file.name}

- -
- {progress !== undefined && ( -
-
- -
- {progress}% -
- )} -
-
-)) +interface FileItemProps { + file: File; + progress?: number; + onRemove: (name: string) => void; + isUploaded: boolean; +} -export default function FileUploader({ - endpoint='', - onSuccess, - onError, - maxSize = 100, - placeholder = '点击或拖拽文件到这里上传', - allowedTypes = ['*/*'] -}: FileUploaderProps) { - const [isDragging, setIsDragging] = useState(false) - const [files, setFiles] = useState([]) - const [progress, setProgress] = useState<{ [key: string]: number }>({}) - const fileInputRef = useRef(null) +const FileItem: React.FC = memo( + ({ file, progress, onRemove, isUploaded }) => ( + + +
+
+

+ {file.name} +

+ +
+ {!isUploaded && progress !== undefined && ( +
+
+ +
+ + {progress}% + +
+ )} + {isUploaded && ( +
+ + 上传完成 +
+ )} +
+
+ ) +); - const handleError = useCallback((error: Error) => { - toast.error(error.message) - onError?.(error) - }, [onError]) +const FileUploader: React.FC = ({ + endpoint = "", + onSuccess, + onError, + maxSize = 100, + placeholder = "点击或拖拽文件到这里上传", + allowedTypes = ["*/*"], +}) => { + const [isDragging, setIsDragging] = useState(false); + const [files, setFiles] = useState< + Array<{ file: File; isUploaded: boolean }> + >([]); + const fileInputRef = useRef(null); - const handleDrag = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.type === 'dragenter' || e.type === 'dragover') { - setIsDragging(true) - } else if (e.type === 'dragleave') { - setIsDragging(false) - } - }, []) + const { progress, isUploading, uploadError, handleFileUpload } = + useTusUpload(); - const validateFile = useCallback((file: File) => { - if (file.size > maxSize * 1024 * 1024) { - throw new Error(`文件大小不能超过 ${maxSize}MB`) - } - if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) { - throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`) - } - }, [maxSize, allowedTypes]) + const handleError = (error: Error) => { + toast.error(error.message); + onError?.(error); + }; - const uploadFile = async (file: File) => { - try { - validateFile(file) + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setIsDragging(true); + } else if (e.type === "dragleave") { + setIsDragging(false); + } + }; - const upload = new tus.Upload(file, { - endpoint, - retryDelays: [0, 3000, 5000, 10000, 20000], - metadata: { - filename: file.name, - filetype: file.type - }, - onError: handleError, - onProgress: (bytesUploaded, bytesTotal) => { - const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2) - setProgress(prev => ({ - ...prev, - [file.name]: parseFloat(percentage) - })) - }, - onSuccess: () => { - onSuccess?.(upload.url || '') - setProgress(prev => { - const newProgress = { ...prev } - delete newProgress[file.name] - return newProgress - }) - } - }) + const validateFile = (file: File) => { + if (file.size > maxSize * 1024 * 1024) { + throw new Error(`文件大小不能超过 ${maxSize}MB`); + } + if ( + !allowedTypes.includes("*/*") && + !allowedTypes.includes(file.type) + ) { + throw new Error( + `不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}` + ); + } + }; - upload.start() - } catch (error) { - handleError(error as Error) - } - } + const uploadFile = (file: File) => { + try { + validateFile(file); + handleFileUpload( + file, + (upload) => { + onSuccess?.(upload.url || ""); + setFiles((prev) => + prev.map((f) => + f.file.name === file.name + ? { ...f, isUploaded: true } + : f + ) + ); + }, + handleError + ); + } catch (error) { + handleError(error as Error); + } + }; - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); - const droppedFiles = Array.from(e.dataTransfer.files) - setFiles(prev => [...prev, ...droppedFiles]) - droppedFiles.forEach(uploadFile) - }, []) + const droppedFiles = Array.from(e.dataTransfer.files); + setFiles((prev) => [ + ...prev, + ...droppedFiles.map((file) => ({ file, isUploaded: false })), + ]); + droppedFiles.forEach(uploadFile); + }; - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files) { - const selectedFiles = Array.from(e.target.files) - setFiles(prev => [...prev, ...selectedFiles]) - selectedFiles.forEach(uploadFile) - } - } + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const selectedFiles = Array.from(e.target.files); + setFiles((prev) => [ + ...prev, + ...selectedFiles.map((file) => ({ file, isUploaded: false })), + ]); + selectedFiles.forEach(uploadFile); + } + }; - const removeFile = (fileName: string) => { - setFiles(prev => prev.filter(file => file.name !== fileName)) - setProgress(prev => { - const newProgress = { ...prev } - delete newProgress[fileName] - return newProgress - }) - } + const removeFile = (fileName: string) => { + setFiles((prev) => prev.filter(({ file }) => file.name !== fileName)); + }; - return ( -
- fileInputRef.current?.click()} - aria-label="文件上传区域" - > - + const handleClick = () => { + fileInputRef.current?.click(); + }; -
- - - -
-

{placeholder}

-
-

- - 支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB -

-
-
+ return ( +
+
+ + +

{placeholder}

+ {isDragging && ( +
+

+ 释放文件以上传 +

+
+ )} +
- -
- {files.map(file => ( - - ))} -
-
-
- ) -} \ No newline at end of file + +
+ {files.map(({ file, isUploaded }) => ( + + ))} +
+
+ + {uploadError && ( +
+ + {uploadError} +
+ )} +
+ ); +}; + +export default FileUploader; diff --git a/apps/web/src/components/presentation/TusUploader.tsx b/apps/web/src/components/presentation/TusUploader.tsx new file mode 100644 index 0000000..3d8b983 --- /dev/null +++ b/apps/web/src/components/presentation/TusUploader.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; // 假设 useTusUpload 在同一个目录下 +import * as tus from "tus-js-client"; +interface TusUploadProps { + onSuccess?: (upload: tus.Upload) => void; + onError?: (error: Error) => void; +} + +export const TusUploader: React.FC = ({ + onSuccess, + onError, +}) => { + const { progress, isUploading, uploadError, handleFileUpload } = + useTusUpload(); + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file, onSuccess, onError); + }} + /> + + {isUploading && ( +
+ + {progress}% +
+ )} + + {uploadError && ( +
上传错误: {uploadError}
+ )} +
+ ); +}; + +export default TusUploader; diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d364783..1f22121 100755 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -2,6 +2,8 @@ export const env: { APP_NAME: string; SERVER_IP: string; VERSION: string; + UOLOAD_PORT: string; + SERVER_PORT: string; } = { APP_NAME: import.meta.env.PROD ? (window as any).env.VITE_APP_APP_NAME @@ -9,6 +11,12 @@ export const env: { SERVER_IP: import.meta.env.PROD ? (window as any).env.VITE_APP_SERVER_IP : import.meta.env.VITE_APP_SERVER_IP, + UOLOAD_PORT: import.meta.env.PROD + ? (window as any).env.VITE_APP_UOLOAD_PORT + : import.meta.env.VITE_APP_UOLOAD_PORT, + SERVER_PORT: import.meta.env.PROD + ? (window as any).env.VITE_APP_SERVER_PORT + : import.meta.env.VITE_APP_SERVER_PORT, VERSION: import.meta.env.PROD ? (window as any).env.VITE_APP_VERSION : import.meta.env.VITE_APP_VERSION, diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts new file mode 100644 index 0000000..695d5a4 --- /dev/null +++ b/apps/web/src/hooks/useTusUpload.ts @@ -0,0 +1,125 @@ +import { useState } from "react"; +import * as tus from "tus-js-client"; +import { env } from "../env"; +import { getCompressedImageUrl } from "@nice/utils"; +// useTusUpload.ts +interface UploadProgress { + fileId: string; + progress: number; +} + +interface UploadResult { + compressedUrl: string; + url: string; + fileId: string; +} + +export function useTusUpload() { + const [uploadProgress, setUploadProgress] = useState< + Record + >({}); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + const getFileId = (url: string) => { + const parts = url.split("/"); + const uploadIndex = parts.findIndex((part) => part === "upload"); + if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) { + throw new Error("Invalid upload URL format"); + } + return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/"); + }; + const getResourceUrl = (url: string) => { + const parts = url.split("/"); + const uploadIndex = parts.findIndex((part) => part === "upload"); + if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) { + throw new Error("Invalid upload URL format"); + } + const resUrl = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`; + + return resUrl; + }; + const handleFileUpload = async ( + file: File, + onSuccess: (result: UploadResult) => void, + onError: (error: Error) => void, + fileKey: string // 添加文件唯一标识 + ) => { + // if (!file || !file.name || !file.type) { + // const error = new Error("不可上传该类型文件"); + // setUploadError(error.message); + // onError(error); + // return; + // } + + setIsUploading(true); + setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 })); + setUploadError(null); + + try { + const upload = new tus.Upload(file, { + endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`, + retryDelays: [0, 1000, 3000, 5000], + metadata: { + filename: file.name, + filetype: file.type, + size: file.size as any, + }, + onProgress: (bytesUploaded, bytesTotal) => { + const progress = Number( + ((bytesUploaded / bytesTotal) * 100).toFixed(2) + ); + setUploadProgress((prev) => ({ + ...prev, + [fileKey]: progress, + })); + }, + onSuccess: async (payload) => { + try { + if (upload.url) { + const fileId = getFileId(upload.url); + const url = getResourceUrl(upload.url); + setIsUploading(false); + setUploadProgress((prev) => ({ + ...prev, + [fileKey]: 100, + })); + onSuccess({ + compressedUrl: getCompressedImageUrl(url), + url, + fileId, + }); + } + } catch (error) { + const err = + error instanceof Error + ? error + : new Error("Unknown error"); + setIsUploading(false); + setUploadError(err.message); + onError(err); + } + }, + onError: (error) => { + setIsUploading(false); + setUploadError(error.message); + onError(error); + }, + }); + upload.start(); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Upload failed"); + setIsUploading(false); + setUploadError(err.message); + onError(err); + } + }; + + return { + uploadProgress, + isUploading, + uploadError, + handleFileUpload, + }; +} diff --git a/packages/client/src/api/hooks/useCourse.ts b/packages/client/src/api/hooks/useCourse.ts index 5061072..8e09848 100644 --- a/packages/client/src/api/hooks/useCourse.ts +++ b/packages/client/src/api/hooks/useCourse.ts @@ -1,56 +1,76 @@ import { api } from "../trpc"; -export function useCourse() { - const utils = api.useUtils(); - return { - // Queries - findMany: api.course.findMany.useQuery, - findFirst: api.course.findFirst.useQuery, - findManyWithCursor: api.course.findManyWithCursor.useQuery, +// 定义返回类型 +type UseCourseReturn = { + // Queries + findMany: typeof api.course.findMany.useQuery; + findFirst: typeof api.course.findFirst.useQuery; + findManyWithCursor: typeof api.course.findManyWithCursor.useQuery; - // Mutations - create: api.course.create.useMutation({ - onSuccess: () => { - utils.course.invalidate() - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }), - update: api.course.update.useMutation({ - onSuccess: () => { - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }), - createMany: api.course.createMany.useMutation({ - onSuccess: () => { - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }), - deleteMany: api.course.deleteMany.useMutation({ - onSuccess: () => { - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }), - softDeleteByIds: api.course.softDeleteByIds.useMutation({ - onSuccess: () => { - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }), - updateOrder: api.course.updateOrder.useMutation({ - onSuccess: () => { - utils.course.findMany.invalidate(); - utils.course.findManyWithCursor.invalidate(); - utils.course.findManyWithPagination.invalidate() - }, - }) - }; -} \ No newline at end of file + // Mutations + create: ReturnType; + // create: ReturnType; + update: ReturnType; + // update: ReturnType; + createMany: ReturnType; + deleteMany: ReturnType; + softDeleteByIds: ReturnType; + // softDeleteByIds: ReturnType; + updateOrder: ReturnType; + // updateOrder: ReturnType; +}; + +export function useCourse(): UseCourseReturn { + const utils = api.useUtils(); + return { + // Queries + findMany: api.course.findMany.useQuery, + findFirst: api.course.findFirst.useQuery, + findManyWithCursor: api.course.findManyWithCursor.useQuery, + + // Mutations + create: api.course.create.useMutation({ + onSuccess: () => { + utils.course.invalidate(); + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + update: api.course.update.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + createMany: api.course.createMany.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + deleteMany: api.course.deleteMany.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + softDeleteByIds: api.course.softDeleteByIds.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + updateOrder: api.course.updateOrder.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + utils.course.findManyWithPagination.invalidate(); + }, + }), + }; +} diff --git a/packages/client/src/api/hooks/useDepartment.ts b/packages/client/src/api/hooks/useDepartment.ts index 27e9c19..d12928b 100755 --- a/packages/client/src/api/hooks/useDepartment.ts +++ b/packages/client/src/api/hooks/useDepartment.ts @@ -50,35 +50,6 @@ export function useDepartment() { return node; }); }; - - // const getTreeData = () => { - // const uniqueData: DepartmentDto[] = getCacheDataFromQuery( - // queryClient, - // api.department - // ); - // const treeData: DataNode[] = buildTree(uniqueData); - // return treeData; - // }; - // const getTreeData = () => { - // const cacheArray = queryClient.getQueriesData({ - // queryKey: getQueryKey(api.department.getChildren), - // }); - // const data: DepartmentDto[] = cacheArray - // .flatMap((cache) => cache.slice(1)) - // .flat() - // .filter((item) => item !== undefined) as any; - // const uniqueDataMap = new Map(); - - // data?.forEach((item) => { - // if (item && item.id) { - // uniqueDataMap.set(item.id, item); - // } - // }); - // // Convert the Map back to an array - // const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values()); - // const treeData: DataNode[] = buildTree(uniqueData); - // return treeData; - // }; const getDept = (key: string) => { return findQueryData(queryClient, api.department, key); }; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 3379233..f0a72d9 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,28 +1,20 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "target": "esnext", - "module": "esnext", - "allowJs": true, - "esModuleInterop": true, - "lib": [ - "dom", - "esnext" - ], - "jsx": "react-jsx", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "moduleResolution": "node", - "incremental": true, - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules", - "dist" - ], -} \ No newline at end of file + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "allowJs": true, + "esModuleInterop": true, + "lib": ["dom", "esnext"], + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "moduleResolution": "node", + "incremental": true, + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/common/package.json b/packages/common/package.json index eb5c31c..9c6edea 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,34 +1,35 @@ { - "name": "@nice/common", - "version": "1.0.0", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "private": true, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "generate": "pnpm prisma generate", - "build": "pnpm generate && tsup", - "dev": "pnpm generate && tsup --watch ", - "studio": "pnpm prisma studio", - "db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init", - "postinstall": "pnpm generate" - }, - "dependencies": { - "@prisma/client": "5.17.0", - "prisma": "5.17.0" - }, - "peerDependencies": { - "zod": "^3.23.8", - "yjs": "^13.6.20", - "lib0": "^0.2.98" - }, - "devDependencies": { - "@types/node": "^20.3.1", - "ts-node": "^10.9.1", - "typescript": "^5.5.4", - "concurrently": "^8.0.0", - "tsup": "^8.3.5", - "rimraf": "^6.0.1" - } -} \ No newline at end of file + "name": "@nice/common", + "version": "1.0.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "generate": "pnpm prisma generate", + "build": "pnpm generate && tsup", + "dev": "pnpm generate && tsup --watch ", + "dev-nowatch": "pnpm generate && tsup --no-watch ", + "studio": "pnpm prisma studio", + "db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init", + "postinstall": "pnpm generate" + }, + "dependencies": { + "@prisma/client": "5.17.0", + "prisma": "5.17.0" + }, + "peerDependencies": { + "zod": "^3.23.8", + "yjs": "^13.6.20", + "lib0": "^0.2.98" + }, + "devDependencies": { + "@types/node": "^20.3.1", + "ts-node": "^10.9.1", + "typescript": "^5.5.4", + "concurrently": "^8.0.0", + "tsup": "^8.3.5", + "rimraf": "^6.0.1" + } +} diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index 7c5b46e..cd9979e 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -74,16 +74,16 @@ export const courseDetailSelect: Prisma.CourseSelect = { level: true, requirements: true, objectives: true, - skills: true, - audiences: true, - totalDuration: true, - totalLectures: true, - averageRating: true, - numberOfReviews: true, - numberOfStudents: true, - completionRate: true, + // skills: true, + // audiences: true, + // totalDuration: true, + // totalLectures: true, + // averageRating: true, + // numberOfReviews: true, + // numberOfStudents: true, + // completionRate: true, status: true, - isFeatured: true, + // isFeatured: true, createdAt: true, publishedAt: true, // 关联表选择 diff --git a/packages/common/tsup.config.ts b/packages/common/tsup.config.ts index 1eacf7a..8dc73a1 100644 --- a/packages/common/tsup.config.ts +++ b/packages/common/tsup.config.ts @@ -1,10 +1,18 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from "tsup"; export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - splitting: false, - sourcemap: true, - clean: false, - dts: true + entry: ["src/index.ts"], + format: ["cjs", "esm"], + splitting: false, + sourcemap: true, + clean: false, + dts: true, + // watch 可以是布尔值或字符串数组 + watch: [ + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.spec.ts", + "!node_modules/**", + "!dist/**", + ], }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 8f3fd0e..c02c2c3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "dev-static": "tsup --no-watch", "clean": "rimraf dist", "typecheck": "tsc --noEmit" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index aea398b..77aa06e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -4,20 +4,38 @@ * @returns 唯一ID字符串 */ export function generateUniqueId(prefix?: string): string { - // 获取当前时间戳 - const timestamp = Date.now(); + // 获取当前时间戳 + const timestamp = Date.now(); - // 生成随机数部分 - const randomPart = Math.random().toString(36).substring(2, 8); + // 生成随机数部分 + const randomPart = Math.random().toString(36).substring(2, 8); - // 获取环境特定的额外随机性 - const environmentPart = typeof window !== 'undefined' - ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) - : require('crypto').randomBytes(4).toString('hex'); + // 获取环境特定的额外随机性 + const environmentPart = + typeof window !== "undefined" + ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) + : require("crypto").randomBytes(4).toString("hex"); - // 组合所有部分 - const uniquePart = `${timestamp}${randomPart}${environmentPart}`; + // 组合所有部分 + const uniquePart = `${timestamp}${randomPart}${environmentPart}`; - // 如果提供了前缀,则添加前缀 - return prefix ? `${prefix}_${uniquePart}` : uniquePart; -} \ No newline at end of file + // 如果提供了前缀,则添加前缀 + return prefix ? `${prefix}_${uniquePart}` : uniquePart; +} +export const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; +// 压缩图片路径生成函数 +export const getCompressedImageUrl = (originalUrl: string): string => { + if (!originalUrl) { + return originalUrl; + } + const cleanUrl = originalUrl.split(/[?#]/)[0]; // 移除查询参数和哈希 + const lastSlashIndex = cleanUrl.lastIndexOf("/"); + return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`; +}; +export * from "./types"; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts new file mode 100644 index 0000000..d3ed883 --- /dev/null +++ b/packages/utils/src/types.ts @@ -0,0 +1 @@ +export type NonVoid = T extends void ? never : T; \ No newline at end of file