This commit is contained in:
ditiqi 2025-01-27 22:43:31 +08:00
parent 52555bc645
commit 6777149a0c
29 changed files with 1058 additions and 888 deletions

View File

@ -1,16 +1,12 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class PostProcessService { export class PostProcessService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {}
@InjectQueue('general') private generalQueue: Queue
) {
}
private generateJobId(type: ObjectType, data: any): string { private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识 // 根据类型和相关ID生成唯一的job标识

View File

@ -1,18 +1,16 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class StatsService { export class StatsService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {
@InjectQueue('general') private generalQueue: Queue EventBus.on('dataChanged', async ({ type, data }) => {
) {
EventBus.on("dataChanged", async ({ type, data }) => {
const jobOptions = { const jobOptions = {
removeOnComplete: true, removeOnComplete: true,
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务 jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
}; };
switch (type) { switch (type) {
case ObjectType.ENROLLMENT: case ObjectType.ENROLLMENT:
@ -20,9 +18,9 @@ export class StatsService {
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.ENROLLMENT type: ObjectType.ENROLLMENT,
}, },
jobOptions jobOptions,
); );
break; break;
@ -32,9 +30,9 @@ export class StatsService {
{ {
sectionId: data.sectionId, sectionId: data.sectionId,
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.LECTURE type: ObjectType.LECTURE,
}, },
jobOptions jobOptions,
); );
break; break;
@ -44,9 +42,9 @@ export class StatsService {
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.POST type: ObjectType.POST,
}, },
jobOptions jobOptions,
); );
} }
break; break;

View File

@ -7,10 +7,10 @@ import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor
const logger = new Logger('FileProcessorWorker'); const logger = new Logger('FileProcessorWorker');
const pipeline = new ResourceProcessingPipeline() const pipeline = new ResourceProcessingPipeline()
.addProcessor(new ImageProcessor()) .addProcessor(new ImageProcessor())
.addProcessor(new VideoProcessor()) .addProcessor(new VideoProcessor());
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.FILE_PROCESS) { if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job) console.log('job', job);
const { resource } = job.data; const { resource } = job.data;
if (!resource) { if (!resource) {
throw new Error('No resource provided in job data'); throw new Error('No resource provided in job data');

View File

@ -2,12 +2,12 @@ import { Job } from 'bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { import {
updateCourseLectureStats, updateCourseLectureStats,
updateSectionLectureStats updateSectionLectureStats,
} from '@server/models/lecture/utils'; } from '@server/models/lecture/utils';
import { ObjectType } from '@nice/common'; import { ObjectType } from '@nice/common';
import { import {
updateCourseEnrollmentStats, updateCourseEnrollmentStats,
updateCourseReviewStats updateCourseReviewStats,
} from '@server/models/course/utils'; } from '@server/models/course/utils';
import { QueueJobType } from '../types'; import { QueueJobType } from '../types';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
@ -39,11 +39,14 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
logger.warn(`Unknown update stats type: ${type}`); logger.warn(`Unknown update stats type: ${type}`);
} }
logger.debug(`Updated course stats for courseId: ${courseId}, type: ${type}`); logger.debug(
`Updated course stats for courseId: ${courseId}, type: ${type}`,
);
} }
} catch (error: any) { } catch (error: any) {
logger.error(`Error processing stats update job: ${error.message}`, error.stack); logger.error(
`Error processing stats update job: ${error.message}`,
error.stack,
);
} }
} }

View File

@ -19,7 +19,7 @@ export class InitService {
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly genDevService: GenDevService, private readonly genDevService: GenDevService,
) { } ) {}
private async createRoles() { private async createRoles() {
this.logger.log('Checking existing system roles'); this.logger.log('Checking existing system roles');
for (const role of InitRoles) { for (const role of InitRoles) {

View File

@ -1,7 +1,7 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; 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 { FileStore } from '@nice/tus';
import { Request, Response } from "express" import { Request, Response } from 'express';
import { db, ResourceStatus } from '@nice/common'; import { db, ResourceStatus } from '@nice/common';
import { getFilenameWithoutExt } from '@server/utils/file'; import { getFilenameWithoutExt } from '@server/utils/file';
import { ResourceService } from '@server/models/resource/resource.service'; import { ResourceService } from '@server/models/resource/resource.service';
@ -14,15 +14,16 @@ import { slugify } from 'transliteration';
const FILE_UPLOAD_CONFIG = { const FILE_UPLOAD_CONFIG = {
directory: process.env.UPLOAD_DIR, directory: process.env.UPLOAD_DIR,
maxSizeBytes: 20_000_000_000, // 20GB maxSizeBytes: 20_000_000_000, // 20GB
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours
}; };
@Injectable() @Injectable()
export class TusService implements OnModuleInit { export class TusService implements OnModuleInit {
private readonly logger = new Logger(TusService.name); private readonly logger = new Logger(TusService.name);
private tusServer: Server; private tusServer: Server;
constructor(private readonly resourceService: ResourceService, constructor(
@InjectQueue("file-queue") private fileQueue: Queue private readonly resourceService: ResourceService,
) { } @InjectQueue('file-queue') private fileQueue: Queue,
) {}
onModuleInit() { onModuleInit() {
this.initializeTusServer(); this.initializeTusServer();
this.setupTusEventHandlers(); this.setupTusEventHandlers();
@ -41,50 +42,64 @@ export class TusService implements OnModuleInit {
path: '/upload', path: '/upload',
datastore: new FileStore({ datastore: new FileStore({
directory: FILE_UPLOAD_CONFIG.directory, directory: FILE_UPLOAD_CONFIG.directory,
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
}), }),
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
postReceiveInterval: 1000, postReceiveInterval: 1000,
getFileIdFromRequest: (req, lastPath) => { getFileIdFromRequest: (req, lastPath) => {
const match = req.url.match(/\/upload\/(.+)/); const match = req.url.match(/\/upload\/(.+)/);
return match ? match[1] : lastPath; return match ? match[1] : lastPath;
} },
}); });
} }
private setupTusEventHandlers() { private setupTusEventHandlers() {
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this)); this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this));
this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this)); this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this));
} }
private getFileId(uploadId: string) { private getFileId(uploadId: string) {
return uploadId.replace(/\/[^/]+$/, '') return uploadId.replace(/\/[^/]+$/, '');
} }
private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) { private async handleUploadCreate(
req: Request,
res: Response,
upload: Upload,
url: string,
) {
try { try {
const fileId = this.getFileId(upload.id);
const fileId = this.getFileId(upload.id) // const filename = upload.metadata.filename;
const filename = upload.metadata.filename
await this.resourceService.create({ await this.resourceService.create({
data: { data: {
title: getFilenameWithoutExt(upload.metadata.filename), title: getFilenameWithoutExt(upload.metadata.filename),
fileId, // 移除最后的文件名 fileId, // 移除最后的文件名
url: upload.id, url: upload.id,
metadata: upload.metadata, metadata: upload.metadata,
status: ResourceStatus.UPLOADING status: ResourceStatus.UPLOADING,
} },
}); });
} catch (error) { } catch (error) {
this.logger.error('Failed to create resource during upload', error); this.logger.error('Failed to create resource during upload', error);
} }
} }
private async handleUploadFinish(req: Request, res: Response, upload: Upload) { private async handleUploadFinish(
req: Request,
res: Response,
upload: Upload,
) {
try { try {
console.log('upload.id', upload.id);
console.log('fileId', this.getFileId(upload.id));
const resource = await this.resourceService.update({ const resource = await this.resourceService.update({
where: { fileId: this.getFileId(upload.id) }, where: { fileId: this.getFileId(upload.id) },
data: { status: ResourceStatus.UPLOADED } data: { status: ResourceStatus.UPLOADED },
}); });
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id }) this.fileQueue.add(
QueueJobType.FILE_PROCESS,
{ resource },
{ jobId: resource.id },
);
this.logger.log(`Upload finished ${resource.url}`); this.logger.log(`Upload finished ${resource.url}`);
} catch (error) { } catch (error) {
this.logger.error('Failed to update resource after upload', error); this.logger.error('Failed to update resource after upload', error);
@ -97,19 +112,22 @@ export class TusService implements OnModuleInit {
// Delete incomplete uploads older than 24 hours // Delete incomplete uploads older than 24 hours
const deletedResources = await db.resource.deleteMany({ const deletedResources = await db.resource.deleteMany({
where: { where: {
createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) }, createdAt: {
status: ResourceStatus.UPLOADING lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
} },
status: ResourceStatus.UPLOADING,
},
}); });
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`); this.logger.log(
`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`,
);
} catch (error) { } catch (error) {
this.logger.error('Expired uploads cleanup failed', error); this.logger.error('Expired uploads cleanup failed', error);
} }
} }
async handleTus(req: Request, res: Response) { async handleTus(req: Request, res: Response) {
return this.tusServer.handle(req, res); return this.tusServer.handle(req, res);
} }
} }

View File

@ -7,10 +7,15 @@ export interface UploadCompleteEvent {
} }
export type UploadEvent = { export type UploadEvent = {
uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean }; uploadStart: {
uploadComplete: UploadCompleteEvent identifier: string;
uploadError: { identifier: string; error: string, filename: string }; filename: string;
} totalSize: number;
resuming?: boolean;
};
uploadComplete: UploadCompleteEvent;
uploadError: { identifier: string; error: string; filename: string };
};
export interface UploadLock { export interface UploadLock {
clientId: string; clientId: string;
timestamp: number; timestamp: number;

View File

@ -11,18 +11,17 @@ import {
Head, Head,
Options, Options,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from "express" import { Request, Response } from 'express';
import { TusService } from './tus.service'; import { TusService } from './tus.service';
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
constructor(private readonly tusService: TusService) { } constructor(private readonly tusService: TusService) {}
// @Post() // @Post()
// async handlePost(@Req() req: Request, @Res() res: Response) { // async handlePost(@Req() req: Request, @Res() res: Response) {
// return this.tusService.handleTus(req, res); // return this.tusService.handleTus(req, res);
// } // }
@Options() @Options()
async handleOptions(@Req() req: Request, @Res() res: Response) { async handleOptions(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
@ -37,12 +36,12 @@ export class UploadController {
async handlePost(@Req() req: Request, @Res() res: Response) { async handlePost(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Get("/*") @Get('/*')
async handleGet(@Req() req: Request, @Res() res: Response) { async handleGet(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Patch("/*") @Patch('/*')
async handlePatch(@Req() req: Request, @Res() res: Response) { async handlePatch(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }

View File

@ -9,9 +9,9 @@ import { ResourceModule } from '@server/models/resource/resource.module';
BullModule.registerQueue({ BullModule.registerQueue({
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致 name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
}), }),
ResourceModule ResourceModule,
], ],
controllers: [UploadController], controllers: [UploadController],
providers: [TusService], providers: [TusService],
}) })
export class UploadModule { } export class UploadModule {}

View File

@ -1,4 +1,3 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import path from 'path'; import path from 'path';

View File

@ -3,7 +3,7 @@ import * as Minio from 'minio';
@Injectable() @Injectable()
export class MinioService { export class MinioService {
private readonly logger = new Logger(MinioService.name) private readonly logger = new Logger(MinioService.name);
private readonly minioClient: Minio.Client; private readonly minioClient: Minio.Client;
constructor() { constructor() {
this.minioClient = new Minio.Client({ this.minioClient = new Minio.Client({
@ -11,7 +11,7 @@ export class MinioService {
port: parseInt(process.env.MINIO_PORT || '9000'), port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: false, useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin' secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
}); });
} }
async createBucket(bucketName: string): Promise<void> { async createBucket(bucketName: string): Promise<void> {

View File

@ -35,6 +35,7 @@
"@nice/iconer": "workspace:^", "@nice/iconer": "workspace:^",
"@nice/mindmap": "workspace:^", "@nice/mindmap": "workspace:^",
"@nice/ui": "workspace:^", "@nice/ui": "workspace:^",
"@nice/utils": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21", "@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-persist-client": "^5.51.9", "@tanstack/react-query-persist-client": "^5.51.9",

View File

@ -1,5 +1,4 @@
import CourseDetail from "@web/src/components/models/course/detail/CourseDetail"; 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"; import { useParams } from "react-router-dom";
export function CourseDetailPage() { export function CourseDetailPage() {

View File

@ -52,7 +52,7 @@ export default function InstructorCoursesPage() {
renderItem={(course) => ( renderItem={(course) => (
<CourseCard <CourseCard
onClick={() => { onClick={() => {
navigate(`/course/${course.id}/manage`, { navigate(`/course/${course.id}/editor`, {
replace: true, replace: true,
}); });
}} }}

View File

@ -1,84 +1,25 @@
import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor'; import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor";
import MindMapEditor from '@web/src/components/presentation/mind-map'; import FileUploader from "@web/src/components/common/uploader/FileUploader";
import React, { useState, useCallback } from 'react';
import * as tus from 'tus-js-client'; import React, { useState, useCallback } from "react";
import * as tus from "tus-js-client";
interface TusUploadProps { interface TusUploadProps {
onSuccess?: (response: any) => void; onSuccess?: (response: any) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
const TusUploader: React.FC<TusUploadProps> = ({ const HomePage: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
onSuccess,
onError
}) => {
const [progress, setProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [uploadError, setUploadError] = useState<string | null>(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 ( return (
<div> <div>
<div className='w-full' style={{ height: 800 }}> <FileUploader></FileUploader>
<div className="w-full" style={{ height: 800 }}>
<GraphEditor></GraphEditor> <GraphEditor></GraphEditor>
</div> </div>
{/* <div className=' h-screen'> {/* <div className=' h-screen'>
<MindMap></MindMap> <MindMap></MindMap>
</div> */} </div> */}
{/* <MindMapEditor></MindMapEditor> */} {/* <MindMapEditor></MindMapEditor> */}
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
{isUploading && (
<div>
<progress value={progress} max="100" />
<span>{progress}%</span>
</div>
)}
{uploadError && (
<div style={{ color: 'red' }}>
: {uploadError}
</div>
)}
</div> </div>
); );
}; };
export default TusUploader; export default HomePage;

View File

@ -1,41 +1,53 @@
import { useState, useCallback, useRef, memo } from 'react' // FileUploader.tsx
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline' import React, { useRef, memo, useState } from "react";
import * as tus from 'tus-js-client' import {
import { motion, AnimatePresence } from 'framer-motion' CloudArrowUpIcon,
import { toast } from 'react-hot-toast' 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 { interface FileUploaderProps {
endpoint?: string endpoint?: string;
onSuccess?: (url: string) => void onSuccess?: (url: string) => void;
onError?: (error: Error) => void onError?: (error: Error) => void;
maxSize?: number maxSize?: number;
allowedTypes?: string[] allowedTypes?: string[];
placeholder?: string placeholder?: string;
} }
const FileItem = memo(({ file, progress, onRemove }: { interface FileItemProps {
file: File file: File;
progress?: number progress?: number;
onRemove: (name: string) => void onRemove: (name: string) => void;
}) => ( isUploaded: boolean;
}
const FileItem: React.FC<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200" className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
>
<DocumentIcon className="w-8 h-8 text-blue-500/80" /> <DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p> <p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
{file.name}
</p>
<button <button
onClick={() => onRemove(file.name)} onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full" className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`} aria-label={`Remove ${file.name}`}>
>
<XMarkIcon className="w-5 h-5 text-gray-500" /> <XMarkIcon className="w-5 h-5 text-gray-500" />
</button> </button>
</div> </div>
{progress !== undefined && ( {!isUploaded && progress !== undefined && (
<div className="mt-2"> <div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden"> <div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div <motion.div
@ -45,167 +57,181 @@ const FileItem = memo(({ file, progress, onRemove }: {
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
/> />
</div> </div>
<span className="text-xs text-gray-500 mt-1">{progress}%</span> <span className="text-xs text-gray-500 mt-1">
{progress}%
</span>
</div>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div> </div>
)} )}
</div> </div>
</motion.div> </motion.div>
)) )
);
export default function FileUploader({ const FileUploader: React.FC<FileUploaderProps> = ({
endpoint='', endpoint = "",
onSuccess, onSuccess,
onError, onError,
maxSize = 100, maxSize = 100,
placeholder = '点击或拖拽文件到这里上传', placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ['*/*'] allowedTypes = ["*/*"],
}: FileUploaderProps) { }) => {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<
const [progress, setProgress] = useState<{ [key: string]: number }>({}) Array<{ file: File; isUploaded: boolean }>
const fileInputRef = useRef<HTMLInputElement>(null) >([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleError = useCallback((error: Error) => { const { progress, isUploading, uploadError, handleFileUpload } =
toast.error(error.message) useTusUpload();
onError?.(error)
}, [onError])
const handleDrag = useCallback((e: React.DragEvent) => { const handleError = (error: Error) => {
e.preventDefault() toast.error(error.message);
e.stopPropagation() onError?.(error);
if (e.type === 'dragenter' || e.type === 'dragover') { };
setIsDragging(true)
} else if (e.type === 'dragleave') { const handleDrag = (e: React.DragEvent) => {
setIsDragging(false) e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
} }
}, []) };
const validateFile = useCallback((file: File) => { const validateFile = (file: File) => {
if (file.size > maxSize * 1024 * 1024) { if (file.size > maxSize * 1024 * 1024) {
throw new Error(`文件大小不能超过 ${maxSize}MB`) throw new Error(`文件大小不能超过 ${maxSize}MB`);
} }
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) { if (
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`) !allowedTypes.includes("*/*") &&
!allowedTypes.includes(file.type)
) {
throw new Error(
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
);
} }
}, [maxSize, allowedTypes]) };
const uploadFile = async (file: File) => { const uploadFile = (file: File) => {
try { try {
validateFile(file) validateFile(file);
handleFileUpload(
const upload = new tus.Upload(file, { file,
endpoint, (upload) => {
retryDelays: [0, 3000, 5000, 10000, 20000], onSuccess?.(upload.url || "");
metadata: { setFiles((prev) =>
filename: file.name, prev.map((f) =>
filetype: file.type f.file.name === file.name
? { ...f, isUploaded: true }
: f
)
);
}, },
onError: handleError, 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
})
}
})
upload.start()
} catch (error) { } catch (error) {
handleError(error as Error) handleError(error as Error);
}
} }
};
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
setIsDragging(false) setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files) const droppedFiles = Array.from(e.dataTransfer.files);
setFiles(prev => [...prev, ...droppedFiles]) setFiles((prev) => [
droppedFiles.forEach(uploadFile) ...prev,
}, []) ...droppedFiles.map((file) => ({ file, isUploaded: false })),
]);
droppedFiles.forEach(uploadFile);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
const selectedFiles = Array.from(e.target.files) const selectedFiles = Array.from(e.target.files);
setFiles(prev => [...prev, ...selectedFiles]) setFiles((prev) => [
selectedFiles.forEach(uploadFile) ...prev,
} ...selectedFiles.map((file) => ({ file, isUploaded: false })),
]);
selectedFiles.forEach(uploadFile);
} }
};
const removeFile = (fileName: string) => { const removeFile = (fileName: string) => {
setFiles(prev => prev.filter(file => file.name !== fileName)) setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
setProgress(prev => { };
const newProgress = { ...prev }
delete newProgress[fileName] const handleClick = () => {
return newProgress fileInputRef.current?.click();
}) };
}
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<motion.div <div
className={`relative border-2 border-dashed rounded-xl p-8 transition-all onClick={handleClick}
${isDragging
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
}`}
onDragEnter={handleDrag} onDragEnter={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
role="button" className={`
tabIndex={0} relative flex flex-col items-center justify-center w-full h-32
onClick={() => fileInputRef.current?.click()} border-2 border-dashed rounded-lg cursor-pointer
aria-label="文件上传区域" transition-colors duration-200 ease-in-out
> ${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input <input
type="file"
ref={fileInputRef} ref={fileInputRef}
className="hidden" type="file"
multiple multiple
onChange={handleFileSelect} onChange={handleFileSelect}
accept={allowedTypes.join(',')} accept={allowedTypes.join(",")}
className="hidden"
/> />
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<div className="flex flex-col items-center justify-center space-y-4"> <p className="mt-2 text-sm text-gray-500">{placeholder}</p>
<motion.div {isDragging && (
animate={{ y: isDragging ? -10 : 0 }} <div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
transition={{ type: "spring", stiffness: 300, damping: 20 }} <p className="text-blue-500 font-medium">
>
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
</motion.div>
<div className="text-center">
<p className="text-gray-500">{placeholder}</p>
</div>
<p className="text-xs text-gray-400 flex items-center gap-1">
<ExclamationCircleIcon className="w-4 h-4" />
: {allowedTypes.join(', ')} · : {maxSize}MB
</p> </p>
</div> </div>
</motion.div> )}
</div>
<AnimatePresence> <AnimatePresence>
<div className="space-y-3"> <div className="space-y-3">
{files.map(file => ( {files.map(({ file, isUploaded }) => (
<FileItem <FileItem
key={file.name} key={file.name}
file={file} file={file}
progress={progress[file.name]} progress={isUploaded ? 100 : progress}
onRemove={removeFile} onRemove={removeFile}
isUploaded={isUploaded}
/> />
))} ))}
</div> </div>
</AnimatePresence> </AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div> </div>
) )}
} </div>
);
};
export default FileUploader;

View File

@ -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<TusUploadProps> = ({
onSuccess,
onError,
}) => {
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file, onSuccess, onError);
}}
/>
{isUploading && (
<div>
<progress value={progress} max="100" />
<span>{progress}%</span>
</div>
)}
{uploadError && (
<div style={{ color: "red" }}>: {uploadError}</div>
)}
</div>
);
};
export default TusUploader;

View File

@ -2,6 +2,8 @@ export const env: {
APP_NAME: string; APP_NAME: string;
SERVER_IP: string; SERVER_IP: string;
VERSION: string; VERSION: string;
UOLOAD_PORT: string;
SERVER_PORT: string;
} = { } = {
APP_NAME: import.meta.env.PROD APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME ? (window as any).env.VITE_APP_APP_NAME
@ -9,6 +11,12 @@ export const env: {
SERVER_IP: import.meta.env.PROD SERVER_IP: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_IP ? (window as any).env.VITE_APP_SERVER_IP
: import.meta.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 VERSION: import.meta.env.PROD
? (window as any).env.VITE_APP_VERSION ? (window as any).env.VITE_APP_VERSION
: import.meta.env.VITE_APP_VERSION, : import.meta.env.VITE_APP_VERSION,

View File

@ -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<string, number>
>({});
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(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,
};
}

View File

@ -1,6 +1,26 @@
import { api } from "../trpc"; import { api } from "../trpc";
export function useCourse() { // 定义返回类型
type UseCourseReturn = {
// Queries
findMany: typeof api.course.findMany.useQuery;
findFirst: typeof api.course.findFirst.useQuery;
findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
// Mutations
create: ReturnType<any>;
// create: ReturnType<typeof api.course.create.useMutation>;
update: ReturnType<any>;
// update: ReturnType<typeof api.course.update.useMutation>;
createMany: ReturnType<typeof api.course.createMany.useMutation>;
deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
softDeleteByIds: ReturnType<any>;
// softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
updateOrder: ReturnType<any>;
// updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
};
export function useCourse(): UseCourseReturn {
const utils = api.useUtils(); const utils = api.useUtils();
return { return {
// Queries // Queries
@ -11,46 +31,46 @@ export function useCourse() {
// Mutations // Mutations
create: api.course.create.useMutation({ create: api.course.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.invalidate() utils.course.invalidate();
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
update: api.course.update.useMutation({ update: api.course.update.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
createMany: api.course.createMany.useMutation({ createMany: api.course.createMany.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
deleteMany: api.course.deleteMany.useMutation({ deleteMany: api.course.deleteMany.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
softDeleteByIds: api.course.softDeleteByIds.useMutation({ softDeleteByIds: api.course.softDeleteByIds.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
updateOrder: api.course.updateOrder.useMutation({ updateOrder: api.course.updateOrder.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}) }),
}; };
} }

View File

@ -50,35 +50,6 @@ export function useDepartment() {
return node; 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<string, DepartmentDto>();
// 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 = <T = DepartmentDto>(key: string) => { const getDept = <T = DepartmentDto>(key: string) => {
return findQueryData<T>(queryClient, api.department, key); return findQueryData<T>(queryClient, api.department, key);
}; };

View File

@ -5,10 +5,7 @@
"module": "esnext", "module": "esnext",
"allowJs": true, "allowJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"lib": [ "lib": ["dom", "esnext"],
"dom",
"esnext"
],
"jsx": "react-jsx", "jsx": "react-jsx",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
@ -16,13 +13,8 @@
"outDir": "dist", "outDir": "dist",
"moduleResolution": "node", "moduleResolution": "node",
"incremental": true, "incremental": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
}, },
"include": [ "include": ["src"],
"src" "exclude": ["node_modules", "dist"]
],
"exclude": [
"node_modules",
"dist"
],
} }

View File

@ -10,6 +10,7 @@
"generate": "pnpm prisma generate", "generate": "pnpm prisma generate",
"build": "pnpm generate && tsup", "build": "pnpm generate && tsup",
"dev": "pnpm generate && tsup --watch ", "dev": "pnpm generate && tsup --watch ",
"dev-nowatch": "pnpm generate && tsup --no-watch ",
"studio": "pnpm prisma studio", "studio": "pnpm prisma studio",
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init", "db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
"postinstall": "pnpm generate" "postinstall": "pnpm generate"

View File

@ -74,16 +74,16 @@ export const courseDetailSelect: Prisma.CourseSelect = {
level: true, level: true,
requirements: true, requirements: true,
objectives: true, objectives: true,
skills: true, // skills: true,
audiences: true, // audiences: true,
totalDuration: true, // totalDuration: true,
totalLectures: true, // totalLectures: true,
averageRating: true, // averageRating: true,
numberOfReviews: true, // numberOfReviews: true,
numberOfStudents: true, // numberOfStudents: true,
completionRate: true, // completionRate: true,
status: true, status: true,
isFeatured: true, // isFeatured: true,
createdAt: true, createdAt: true,
publishedAt: true, publishedAt: true,
// 关联表选择 // 关联表选择

View File

@ -1,10 +1,18 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/index.ts"],
format: ['cjs', 'esm'], format: ["cjs", "esm"],
splitting: false, splitting: false,
sourcemap: true, sourcemap: true,
clean: false, clean: false,
dts: true dts: true,
// watch 可以是布尔值或字符串数组
watch: [
"src/**/*.ts",
"!src/**/*.test.ts",
"!src/**/*.spec.ts",
"!node_modules/**",
"!dist/**",
],
}); });

View File

@ -8,6 +8,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@ -11,9 +11,10 @@ export function generateUniqueId(prefix?: string): string {
const randomPart = Math.random().toString(36).substring(2, 8); const randomPart = Math.random().toString(36).substring(2, 8);
// 获取环境特定的额外随机性 // 获取环境特定的额外随机性
const environmentPart = typeof window !== 'undefined' const environmentPart =
typeof window !== "undefined"
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
: require('crypto').randomBytes(4).toString('hex'); : require("crypto").randomBytes(4).toString("hex");
// 组合所有部分 // 组合所有部分
const uniquePart = `${timestamp}${randomPart}${environmentPart}`; const uniquePart = `${timestamp}${randomPart}${environmentPart}`;
@ -21,3 +22,20 @@ export function generateUniqueId(prefix?: string): string {
// 如果提供了前缀,则添加前缀 // 如果提供了前缀,则添加前缀
return prefix ? `${prefix}_${uniquePart}` : uniquePart; 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";

View File

@ -0,0 +1 @@
export type NonVoid<T> = T extends void ? never : T;