add
This commit is contained in:
parent
52555bc645
commit
6777149a0c
|
@ -1,28 +1,24 @@
|
||||||
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标识
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ObjectType.ENROLLMENT:
|
case ObjectType.ENROLLMENT:
|
||||||
return `stats_${type}_${data.courseId}`;
|
return `stats_${type}_${data.courseId}`;
|
||||||
case ObjectType.LECTURE:
|
case ObjectType.LECTURE:
|
||||||
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
||||||
case ObjectType.POST:
|
case ObjectType.POST:
|
||||||
return `stats_${type}_${data.courseId}`;
|
return `stats_${type}_${data.courseId}`;
|
||||||
default:
|
default:
|
||||||
return `stats_${type}_${Date.now()}`;
|
return `stats_${type}_${Date.now()}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,70 +1,68 @@
|
||||||
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 }) => {
|
||||||
) {
|
const jobOptions = {
|
||||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
removeOnComplete: true,
|
||||||
const jobOptions = {
|
jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
|
||||||
removeOnComplete: true,
|
};
|
||||||
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务
|
switch (type) {
|
||||||
};
|
case ObjectType.ENROLLMENT:
|
||||||
switch (type) {
|
await this.generalQueue.add(
|
||||||
case ObjectType.ENROLLMENT:
|
QueueJobType.UPDATE_STATS,
|
||||||
await this.generalQueue.add(
|
{
|
||||||
QueueJobType.UPDATE_STATS,
|
courseId: data.courseId,
|
||||||
{
|
type: ObjectType.ENROLLMENT,
|
||||||
courseId: data.courseId,
|
},
|
||||||
type: ObjectType.ENROLLMENT
|
jobOptions,
|
||||||
},
|
);
|
||||||
jobOptions
|
break;
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ObjectType.LECTURE:
|
case ObjectType.LECTURE:
|
||||||
await this.generalQueue.add(
|
await this.generalQueue.add(
|
||||||
QueueJobType.UPDATE_STATS,
|
QueueJobType.UPDATE_STATS,
|
||||||
{
|
{
|
||||||
sectionId: data.sectionId,
|
sectionId: data.sectionId,
|
||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
type: ObjectType.LECTURE
|
type: ObjectType.LECTURE,
|
||||||
},
|
},
|
||||||
jobOptions
|
jobOptions,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ObjectType.POST:
|
case ObjectType.POST:
|
||||||
if (data.courseId) {
|
if (data.courseId) {
|
||||||
await this.generalQueue.add(
|
await this.generalQueue.add(
|
||||||
QueueJobType.UPDATE_STATS,
|
QueueJobType.UPDATE_STATS,
|
||||||
{
|
{
|
||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
type: ObjectType.POST
|
type: ObjectType.POST,
|
||||||
},
|
},
|
||||||
jobOptions
|
jobOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
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()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor
|
||||||
import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor';
|
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');
|
||||||
}
|
|
||||||
const result = await pipeline.execute(resource);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
const result = await pipeline.execute(resource);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,49 +1,52 @@
|
||||||
import { Job } from 'bullmq';
|
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');
|
||||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
try {
|
try {
|
||||||
if (job.name === QueueJobType.UPDATE_STATS) {
|
if (job.name === QueueJobType.UPDATE_STATS) {
|
||||||
const { sectionId, courseId, type } = job.data;
|
const { sectionId, courseId, type } = job.data;
|
||||||
// 处理 section 统计
|
// 处理 section 统计
|
||||||
if (sectionId) {
|
if (sectionId) {
|
||||||
await updateSectionLectureStats(sectionId);
|
await updateSectionLectureStats(sectionId);
|
||||||
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||||
}
|
}
|
||||||
// 如果没有 courseId,提前返回
|
// 如果没有 courseId,提前返回
|
||||||
if (!courseId) {
|
if (!courseId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 处理 course 相关统计
|
// 处理 course 相关统计
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ObjectType.LECTURE:
|
case ObjectType.LECTURE:
|
||||||
await updateCourseLectureStats(courseId);
|
await updateCourseLectureStats(courseId);
|
||||||
break;
|
break;
|
||||||
case ObjectType.ENROLLMENT:
|
case ObjectType.ENROLLMENT:
|
||||||
await updateCourseEnrollmentStats(courseId);
|
await updateCourseEnrollmentStats(courseId);
|
||||||
break;
|
break;
|
||||||
case ObjectType.POST:
|
case ObjectType.POST:
|
||||||
await updateCourseReviewStats(courseId);
|
await updateCourseReviewStats(courseId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
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) {
|
|
||||||
logger.error(`Error processing stats update job: ${error.message}`, error.stack);
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error: any) {
|
||||||
|
logger.error(
|
||||||
|
`Error processing stats update job: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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';
|
||||||
|
@ -12,104 +12,122 @@ import { QueueJobType } from '@server/queue/types';
|
||||||
import { nanoid } from 'nanoid-cjs';
|
import { nanoid } from 'nanoid-cjs';
|
||||||
import { slugify } from 'transliteration';
|
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() {
|
) {}
|
||||||
this.initializeTusServer();
|
onModuleInit() {
|
||||||
this.setupTusEventHandlers();
|
this.initializeTusServer();
|
||||||
}
|
this.setupTusEventHandlers();
|
||||||
private initializeTusServer() {
|
}
|
||||||
this.tusServer = new Server({
|
private initializeTusServer() {
|
||||||
namingFunction(req, metadata) {
|
this.tusServer = new Server({
|
||||||
const safeFilename = slugify(metadata.filename);
|
namingFunction(req, metadata) {
|
||||||
const now = new Date();
|
const safeFilename = slugify(metadata.filename);
|
||||||
const year = now.getFullYear();
|
const now = new Date();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const year = now.getFullYear();
|
||||||
const day = String(now.getDate()).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const uniqueId = nanoid(10);
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
|
const uniqueId = nanoid(10);
|
||||||
},
|
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
|
||||||
path: '/upload',
|
},
|
||||||
datastore: new FileStore({
|
path: '/upload',
|
||||||
directory: FILE_UPLOAD_CONFIG.directory,
|
datastore: new FileStore({
|
||||||
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod
|
directory: FILE_UPLOAD_CONFIG.directory,
|
||||||
}),
|
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
|
||||||
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
}),
|
||||||
postReceiveInterval: 1000,
|
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
||||||
getFileIdFromRequest: (req, lastPath) => {
|
postReceiveInterval: 1000,
|
||||||
const match = req.url.match(/\/upload\/(.+)/);
|
getFileIdFromRequest: (req, lastPath) => {
|
||||||
return match ? match[1] : lastPath;
|
const match = req.url.match(/\/upload\/(.+)/);
|
||||||
}
|
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) {
|
||||||
|
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)
|
private async handleUploadFinish(
|
||||||
const filename = upload.metadata.filename
|
req: Request,
|
||||||
await this.resourceService.create({
|
res: Response,
|
||||||
data: {
|
upload: Upload,
|
||||||
title: getFilenameWithoutExt(upload.metadata.filename),
|
) {
|
||||||
fileId, // 移除最后的文件名
|
try {
|
||||||
url: upload.id,
|
console.log('upload.id', upload.id);
|
||||||
metadata: upload.metadata,
|
console.log('fileId', this.getFileId(upload.id));
|
||||||
status: ResourceStatus.UPLOADING
|
const resource = await this.resourceService.update({
|
||||||
}
|
where: { fileId: this.getFileId(upload.id) },
|
||||||
});
|
data: { status: ResourceStatus.UPLOADED },
|
||||||
} catch (error) {
|
});
|
||||||
this.logger.error('Failed to create resource during upload', error);
|
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) {
|
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||||
try {
|
async cleanupExpiredUploads() {
|
||||||
const resource = await this.resourceService.update({
|
try {
|
||||||
where: { fileId: this.getFileId(upload.id) },
|
// Delete incomplete uploads older than 24 hours
|
||||||
data: { status: ResourceStatus.UPLOADED }
|
const deletedResources = await db.resource.deleteMany({
|
||||||
});
|
where: {
|
||||||
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id })
|
createdAt: {
|
||||||
this.logger.log(`Upload finished ${resource.url}`);
|
lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
|
||||||
} catch (error) {
|
},
|
||||||
this.logger.error('Failed to update resource after upload', error);
|
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 handleTus(req: Request, res: Response) {
|
||||||
async cleanupExpiredUploads() {
|
return this.tusServer.handle(req, res);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,19 +1,24 @@
|
||||||
export interface UploadCompleteEvent {
|
export interface UploadCompleteEvent {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
size: number;
|
size: number;
|
||||||
hash: string;
|
hash: string;
|
||||||
integrityVerified: boolean;
|
integrityVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
// 添加重试机制,处理临时网络问题
|
// 添加重试机制,处理临时网络问题
|
||||||
// 实现定期清理过期的临时文件
|
// 实现定期清理过期的临时文件
|
||||||
|
@ -21,4 +26,4 @@ export interface UploadLock {
|
||||||
// 实现上传进度持久化,支持服务重启后恢复
|
// 实现上传进度持久化,支持服务重启后恢复
|
||||||
// 添加并发限制,防止系统资源耗尽
|
// 添加并发限制,防止系统资源耗尽
|
||||||
// 实现文件去重功能,避免重复上传
|
// 实现文件去重功能,避免重复上传
|
||||||
// 添加日志记录和监控机制
|
// 添加日志记录和监控机制
|
||||||
|
|
|
@ -1,55 +1,54 @@
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
All,
|
All,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
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()
|
||||||
|
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
||||||
|
return this.tusService.handleTus(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
@Options()
|
@Head()
|
||||||
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
async handleHead(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Head()
|
@Post()
|
||||||
async handleHead(@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('/*')
|
||||||
|
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||||
|
return this.tusService.handleTus(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Patch('/*')
|
||||||
async handlePost(@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);
|
||||||
}
|
}
|
||||||
@Get("/*")
|
|
||||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
|
||||||
return this.tusService.handleTus(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("/*")
|
// Keeping the catch-all method as a fallback
|
||||||
async handlePatch(@Req() req: Request, @Res() res: Response) {
|
@All()
|
||||||
return this.tusService.handleTus(req, res);
|
async handleUpload(@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,13 +5,13 @@ import { TusService } from './tus.service';
|
||||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
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 {}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export function extractFileIdFromNginxUrl(url: string) {
|
export function extractFileIdFromNginxUrl(url: string) {
|
||||||
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
|
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
|
||||||
return match ? match[1] : '';
|
return match ? match[1] : '';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
export function getFilenameWithoutExt(filename: string) {
|
export function getFilenameWithoutExt(filename: string) {
|
||||||
return filename ? filename.replace(/\.[^/.]+$/, '') : filename;
|
return filename ? filename.replace(/\.[^/.]+$/, '') : filename;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 计算文件的 SHA-256 哈希值
|
* 计算文件的 SHA-256 哈希值
|
||||||
|
@ -13,31 +12,31 @@ export function getFilenameWithoutExt(filename: string) {
|
||||||
* @returns Promise<string> 返回文件的哈希值(十六进制字符串)
|
* @returns Promise<string> 返回文件的哈希值(十六进制字符串)
|
||||||
*/
|
*/
|
||||||
export async function calculateFileHash(filePath: string): Promise<string> {
|
export async function calculateFileHash(filePath: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 创建一个 SHA-256 哈希对象
|
// 创建一个 SHA-256 哈希对象
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
// 创建文件读取流
|
// 创建文件读取流
|
||||||
const readStream = createReadStream(filePath);
|
const readStream = createReadStream(filePath);
|
||||||
// 处理读取错误
|
// 处理读取错误
|
||||||
readStream.on('error', (error) => {
|
readStream.on('error', (error) => {
|
||||||
reject(new Error(`Failed to read file: ${error.message}`));
|
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}`));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
// 处理哈希计算错误
|
||||||
|
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<string> {
|
||||||
* @returns string 返回 Buffer 的哈希值(十六进制字符串)
|
* @returns string 返回 Buffer 的哈希值(十六进制字符串)
|
||||||
*/
|
*/
|
||||||
export function calculateBufferHash(buffer: Buffer): string {
|
export function calculateBufferHash(buffer: Buffer): string {
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
hash.update(buffer);
|
hash.update(buffer);
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,11 +56,11 @@ export function calculateBufferHash(buffer: Buffer): string {
|
||||||
* @returns string 返回字符串的哈希值(十六进制字符串)
|
* @returns string 返回字符串的哈希值(十六进制字符串)
|
||||||
*/
|
*/
|
||||||
export function calculateStringHash(content: string): string {
|
export function calculateStringHash(content: string): string {
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
hash.update(content);
|
hash.update(content);
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
export const getUploadFilePath = (fileId: string): string => {
|
export const getUploadFilePath = (fileId: string): string => {
|
||||||
const uploadDirectory = process.env.UPLOAD_DIR;
|
const uploadDirectory = process.env.UPLOAD_DIR;
|
||||||
return path.join(uploadDirectory, fileId);
|
return path.join(uploadDirectory, fileId);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,24 +3,24 @@ 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({
|
||||||
endPoint: process.env.MINIO_HOST || 'localhost',
|
endPoint: process.env.MINIO_HOST || 'localhost',
|
||||||
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> {
|
||||||
const exists = await this.minioClient.bucketExists(bucketName);
|
const exists = await this.minioClient.bucketExists(bucketName);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await this.minioClient.makeBucket(bucketName, '');
|
await this.minioClient.makeBucket(bucketName, '');
|
||||||
this.logger.log(`Bucket ${bucketName} created successfully.`);
|
this.logger.log(`Bucket ${bucketName} created successfully.`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`Bucket ${bucketName} already exists.`);
|
this.logger.log(`Bucket ${bucketName} already exists.`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,92 +1,93 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ag-grid-community/client-side-row-model": "~32.3.2",
|
"@ag-grid-community/client-side-row-model": "~32.3.2",
|
||||||
"@ag-grid-community/core": "~32.3.2",
|
"@ag-grid-community/core": "~32.3.2",
|
||||||
"@ag-grid-community/react": "~32.3.2",
|
"@ag-grid-community/react": "~32.3.2",
|
||||||
"@ag-grid-enterprise/clipboard": "~32.3.2",
|
"@ag-grid-enterprise/clipboard": "~32.3.2",
|
||||||
"@ag-grid-enterprise/column-tool-panel": "~32.3.2",
|
"@ag-grid-enterprise/column-tool-panel": "~32.3.2",
|
||||||
"@ag-grid-enterprise/core": "~32.3.2",
|
"@ag-grid-enterprise/core": "~32.3.2",
|
||||||
"@ag-grid-enterprise/filter-tool-panel": "~32.3.2",
|
"@ag-grid-enterprise/filter-tool-panel": "~32.3.2",
|
||||||
"@ag-grid-enterprise/master-detail": "~32.3.2",
|
"@ag-grid-enterprise/master-detail": "~32.3.2",
|
||||||
"@ag-grid-enterprise/menu": "~32.3.2",
|
"@ag-grid-enterprise/menu": "~32.3.2",
|
||||||
"@ag-grid-enterprise/range-selection": "~32.3.2",
|
"@ag-grid-enterprise/range-selection": "~32.3.2",
|
||||||
"@ag-grid-enterprise/server-side-row-model": "~32.3.2",
|
"@ag-grid-enterprise/server-side-row-model": "~32.3.2",
|
||||||
"@ag-grid-enterprise/set-filter": "~32.3.2",
|
"@ag-grid-enterprise/set-filter": "~32.3.2",
|
||||||
"@ag-grid-enterprise/status-bar": "~32.3.2",
|
"@ag-grid-enterprise/status-bar": "~32.3.2",
|
||||||
"@ant-design/icons": "^5.4.0",
|
"@ant-design/icons": "^5.4.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@floating-ui/react": "^0.26.25",
|
"@floating-ui/react": "^0.26.25",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@nice/client": "workspace:^",
|
"@nice/client": "workspace:^",
|
||||||
"@nice/common": "workspace:^",
|
"@nice/common": "workspace:^",
|
||||||
"@nice/iconer": "workspace:^",
|
"@nice/iconer": "workspace:^",
|
||||||
"@nice/mindmap": "workspace:^",
|
"@nice/mindmap": "workspace:^",
|
||||||
"@nice/ui": "workspace:^",
|
"@nice/ui": "workspace:^",
|
||||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
"@nice/utils": "workspace:^",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||||
"@tanstack/react-query-persist-client": "^5.51.9",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
"@trpc/client": "11.0.0-rc.456",
|
"@tanstack/react-query-persist-client": "^5.51.9",
|
||||||
"@trpc/react-query": "11.0.0-rc.456",
|
"@trpc/client": "11.0.0-rc.456",
|
||||||
"@trpc/server": "11.0.0-rc.456",
|
"@trpc/react-query": "11.0.0-rc.456",
|
||||||
"@xyflow/react": "^12.3.6",
|
"@trpc/server": "11.0.0-rc.456",
|
||||||
"ag-grid-community": "~32.3.2",
|
"@xyflow/react": "^12.3.6",
|
||||||
"ag-grid-enterprise": "~32.3.2",
|
"ag-grid-community": "~32.3.2",
|
||||||
"ag-grid-react": "~32.3.2",
|
"ag-grid-enterprise": "~32.3.2",
|
||||||
"antd": "^5.19.3",
|
"ag-grid-react": "~32.3.2",
|
||||||
"axios": "^1.7.2",
|
"antd": "^5.19.3",
|
||||||
"browser-image-compression": "^2.0.2",
|
"axios": "^1.7.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"browser-image-compression": "^2.0.2",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"d3-dag": "^1.1.0",
|
"clsx": "^2.1.1",
|
||||||
"d3-hierarchy": "^3.1.2",
|
"d3-dag": "^1.1.0",
|
||||||
"dayjs": "^1.11.12",
|
"d3-hierarchy": "^3.1.2",
|
||||||
"elkjs": "^0.9.3",
|
"dayjs": "^1.11.12",
|
||||||
"framer-motion": "^11.15.0",
|
"elkjs": "^0.9.3",
|
||||||
"hls.js": "^1.5.18",
|
"framer-motion": "^11.15.0",
|
||||||
"idb-keyval": "^6.2.1",
|
"hls.js": "^1.5.18",
|
||||||
"mitt": "^3.0.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"quill": "2.0.3",
|
"mitt": "^3.0.1",
|
||||||
"react": "18.2.0",
|
"quill": "2.0.3",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-resizable": "^3.0.5",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-router-dom": "^6.24.1",
|
"react-resizable": "^3.0.5",
|
||||||
"superjson": "^2.2.1",
|
"react-router-dom": "^6.24.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"superjson": "^2.2.1",
|
||||||
"uuid": "^10.0.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"yjs": "^13.6.20",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^3.23.8"
|
"yjs": "^13.6.20",
|
||||||
},
|
"zod": "^3.23.8"
|
||||||
"devDependencies": {
|
},
|
||||||
"@eslint/js": "^9.9.0",
|
"devDependencies": {
|
||||||
"@types/react": "18.2.38",
|
"@eslint/js": "^9.9.0",
|
||||||
"@types/react-dom": "18.2.15",
|
"@types/react": "18.2.38",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@types/react-dom": "18.2.15",
|
||||||
"autoprefixer": "^10.4.20",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"eslint": "^9.9.0",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint": "^9.9.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.9",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"globals": "^15.9.0",
|
"eslint-plugin-react-refresh": "^0.4.9",
|
||||||
"postcss": "^8.4.41",
|
"globals": "^15.9.0",
|
||||||
"tailwindcss": "^3.4.10",
|
"postcss": "^8.4.41",
|
||||||
"typescript": "^5.5.4",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript-eslint": "^8.0.1",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.1"
|
"typescript-eslint": "^8.0.1",
|
||||||
}
|
"vite": "^5.4.1"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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,
|
return (
|
||||||
onError
|
<div>
|
||||||
}) => {
|
<FileUploader></FileUploader>
|
||||||
const [progress, setProgress] = useState<number>(0);
|
<div className="w-full" style={{ height: 800 }}>
|
||||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
<GraphEditor></GraphEditor>
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
</div>
|
||||||
const handleFileUpload = useCallback((file: File) => {
|
{/* <div className=' h-screen'>
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<div className='w-full' style={{ height: 800 }}>
|
|
||||||
<GraphEditor></GraphEditor>
|
|
||||||
</div>
|
|
||||||
{/* <div className=' h-screen'>
|
|
||||||
<MindMap></MindMap>
|
<MindMap></MindMap>
|
||||||
</div> */}
|
</div> */}
|
||||||
{/* <MindMapEditor></MindMapEditor> */}
|
{/* <MindMapEditor></MindMapEditor> */}
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TusUploader;
|
export default HomePage;
|
||||||
|
|
|
@ -1,211 +1,237 @@
|
||||||
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;
|
||||||
<motion.div
|
}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => onRemove(file.name)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{progress !== undefined && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
className="bg-blue-500 h-1.5 rounded-full"
|
|
||||||
initial={{ width: 0 }}
|
|
||||||
animate={{ width: `${progress}%` }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))
|
|
||||||
|
|
||||||
export default function FileUploader({
|
const FileItem: React.FC<FileItemProps> = memo(
|
||||||
endpoint='',
|
({ file, progress, onRemove, isUploaded }) => (
|
||||||
onSuccess,
|
<motion.div
|
||||||
onError,
|
initial={{ opacity: 0, y: 20 }}
|
||||||
maxSize = 100,
|
animate={{ opacity: 1, y: 0 }}
|
||||||
placeholder = '点击或拖拽文件到这里上传',
|
exit={{ opacity: 0, x: -20 }}
|
||||||
allowedTypes = ['*/*']
|
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
|
||||||
}: FileUploaderProps) {
|
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
<div className="ml-3 flex-1">
|
||||||
const [files, setFiles] = useState<File[]>([])
|
<div className="flex items-center justify-between">
|
||||||
const [progress, setProgress] = useState<{ [key: string]: number }>({})
|
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(file.name)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||||
|
aria-label={`Remove ${file.name}`}>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!isUploaded && progress !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const handleError = useCallback((error: Error) => {
|
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||||
toast.error(error.message)
|
endpoint = "",
|
||||||
onError?.(error)
|
onSuccess,
|
||||||
}, [onError])
|
onError,
|
||||||
|
maxSize = 100,
|
||||||
|
placeholder = "点击或拖拽文件到这里上传",
|
||||||
|
allowedTypes = ["*/*"],
|
||||||
|
}) => {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [files, setFiles] = useState<
|
||||||
|
Array<{ file: File; isUploaded: boolean }>
|
||||||
|
>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||||
e.preventDefault()
|
useTusUpload();
|
||||||
e.stopPropagation()
|
|
||||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
|
||||||
setIsDragging(true)
|
|
||||||
} else if (e.type === 'dragleave') {
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const validateFile = useCallback((file: File) => {
|
const handleError = (error: Error) => {
|
||||||
if (file.size > maxSize * 1024 * 1024) {
|
toast.error(error.message);
|
||||||
throw new Error(`文件大小不能超过 ${maxSize}MB`)
|
onError?.(error);
|
||||||
}
|
};
|
||||||
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) {
|
|
||||||
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`)
|
|
||||||
}
|
|
||||||
}, [maxSize, allowedTypes])
|
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
try {
|
e.preventDefault();
|
||||||
validateFile(file)
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const upload = new tus.Upload(file, {
|
const validateFile = (file: File) => {
|
||||||
endpoint,
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
throw new Error(`文件大小不能超过 ${maxSize}MB`);
|
||||||
metadata: {
|
}
|
||||||
filename: file.name,
|
if (
|
||||||
filetype: file.type
|
!allowedTypes.includes("*/*") &&
|
||||||
},
|
!allowedTypes.includes(file.type)
|
||||||
onError: handleError,
|
) {
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
throw new Error(
|
||||||
const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2)
|
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
|
||||||
setProgress(prev => ({
|
);
|
||||||
...prev,
|
}
|
||||||
[file.name]: parseFloat(percentage)
|
};
|
||||||
}))
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
onSuccess?.(upload.url || '')
|
|
||||||
setProgress(prev => {
|
|
||||||
const newProgress = { ...prev }
|
|
||||||
delete newProgress[file.name]
|
|
||||||
return newProgress
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
upload.start()
|
const uploadFile = (file: File) => {
|
||||||
} catch (error) {
|
try {
|
||||||
handleError(error as Error)
|
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) => {
|
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]
|
|
||||||
return newProgress
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const handleClick = () => {
|
||||||
<div className="w-full space-y-4">
|
fileInputRef.current?.click();
|
||||||
<motion.div
|
};
|
||||||
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
|
|
||||||
${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}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
aria-label="文件上传区域"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
accept={allowedTypes.join(',')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4">
|
return (
|
||||||
<motion.div
|
<div className="w-full space-y-4">
|
||||||
animate={{ y: isDragging ? -10 : 0 }}
|
<div
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
onClick={handleClick}
|
||||||
>
|
onDragEnter={handleDrag}
|
||||||
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
|
onDragLeave={handleDrag}
|
||||||
</motion.div>
|
onDragOver={handleDrag}
|
||||||
<div className="text-center">
|
onDrop={handleDrop}
|
||||||
<p className="text-gray-500">{placeholder}</p>
|
className={`
|
||||||
</div>
|
relative flex flex-col items-center justify-center w-full h-32
|
||||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
border-2 border-dashed rounded-lg cursor-pointer
|
||||||
<ExclamationCircleIcon className="w-4 h-4" />
|
transition-colors duration-200 ease-in-out
|
||||||
支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB
|
${
|
||||||
</p>
|
isDragging
|
||||||
</div>
|
? "border-blue-500 bg-blue-50"
|
||||||
</motion.div>
|
: "border-gray-300 hover:border-blue-500"
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept={allowedTypes.join(",")}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||||
|
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||||
|
<p className="text-blue-500 font-medium">
|
||||||
|
释放文件以上传
|
||||||
|
</p>
|
||||||
|
</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>
|
))}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploader;
|
||||||
|
|
|
@ -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;
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,56 +1,76 @@
|
||||||
import { api } from "../trpc";
|
import { api } from "../trpc";
|
||||||
|
|
||||||
export function useCourse() {
|
// 定义返回类型
|
||||||
const utils = api.useUtils();
|
type UseCourseReturn = {
|
||||||
return {
|
// Queries
|
||||||
// Queries
|
findMany: typeof api.course.findMany.useQuery;
|
||||||
findMany: api.course.findMany.useQuery,
|
findFirst: typeof api.course.findFirst.useQuery;
|
||||||
findFirst: api.course.findFirst.useQuery,
|
findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
|
||||||
findManyWithCursor: api.course.findManyWithCursor.useQuery,
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
create: api.course.create.useMutation({
|
create: ReturnType<any>;
|
||||||
onSuccess: () => {
|
// create: ReturnType<typeof api.course.create.useMutation>;
|
||||||
utils.course.invalidate()
|
update: ReturnType<any>;
|
||||||
utils.course.findMany.invalidate();
|
// update: ReturnType<typeof api.course.update.useMutation>;
|
||||||
utils.course.findManyWithCursor.invalidate();
|
createMany: ReturnType<typeof api.course.createMany.useMutation>;
|
||||||
utils.course.findManyWithPagination.invalidate()
|
deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
|
||||||
},
|
softDeleteByIds: ReturnType<any>;
|
||||||
}),
|
// softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
|
||||||
update: api.course.update.useMutation({
|
updateOrder: ReturnType<any>;
|
||||||
onSuccess: () => {
|
// updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
|
||||||
utils.course.findMany.invalidate();
|
};
|
||||||
utils.course.findManyWithCursor.invalidate();
|
|
||||||
utils.course.findManyWithPagination.invalidate()
|
export function useCourse(): UseCourseReturn {
|
||||||
},
|
const utils = api.useUtils();
|
||||||
}),
|
return {
|
||||||
createMany: api.course.createMany.useMutation({
|
// Queries
|
||||||
onSuccess: () => {
|
findMany: api.course.findMany.useQuery,
|
||||||
utils.course.findMany.invalidate();
|
findFirst: api.course.findFirst.useQuery,
|
||||||
utils.course.findManyWithCursor.invalidate();
|
findManyWithCursor: api.course.findManyWithCursor.useQuery,
|
||||||
utils.course.findManyWithPagination.invalidate()
|
|
||||||
},
|
// Mutations
|
||||||
}),
|
create: api.course.create.useMutation({
|
||||||
deleteMany: api.course.deleteMany.useMutation({
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
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();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
softDeleteByIds: api.course.softDeleteByIds.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();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
updateOrder: api.course.updateOrder.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({
|
||||||
}
|
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();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,28 +1,20 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"lib": [
|
"lib": ["dom", "esnext"],
|
||||||
"dom",
|
"jsx": "react-jsx",
|
||||||
"esnext"
|
"declaration": true,
|
||||||
],
|
"declarationMap": true,
|
||||||
"jsx": "react-jsx",
|
"sourceMap": true,
|
||||||
"declaration": true,
|
"outDir": "dist",
|
||||||
"declarationMap": true,
|
"moduleResolution": "node",
|
||||||
"sourceMap": true,
|
"incremental": true,
|
||||||
"outDir": "dist",
|
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
||||||
"moduleResolution": "node",
|
},
|
||||||
"incremental": true,
|
"include": ["src"],
|
||||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
"exclude": ["node_modules", "dist"]
|
||||||
},
|
}
|
||||||
"include": [
|
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,34 +1,35 @@
|
||||||
{
|
{
|
||||||
"name": "@nice/common",
|
"name": "@nice/common",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"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 ",
|
||||||
"studio": "pnpm prisma studio",
|
"dev-nowatch": "pnpm generate && tsup --no-watch ",
|
||||||
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
|
"studio": "pnpm prisma studio",
|
||||||
"postinstall": "pnpm generate"
|
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
|
||||||
},
|
"postinstall": "pnpm generate"
|
||||||
"dependencies": {
|
},
|
||||||
"@prisma/client": "5.17.0",
|
"dependencies": {
|
||||||
"prisma": "5.17.0"
|
"@prisma/client": "5.17.0",
|
||||||
},
|
"prisma": "5.17.0"
|
||||||
"peerDependencies": {
|
},
|
||||||
"zod": "^3.23.8",
|
"peerDependencies": {
|
||||||
"yjs": "^13.6.20",
|
"zod": "^3.23.8",
|
||||||
"lib0": "^0.2.98"
|
"yjs": "^13.6.20",
|
||||||
},
|
"lib0": "^0.2.98"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/node": "^20.3.1",
|
"devDependencies": {
|
||||||
"ts-node": "^10.9.1",
|
"@types/node": "^20.3.1",
|
||||||
"typescript": "^5.5.4",
|
"ts-node": "^10.9.1",
|
||||||
"concurrently": "^8.0.0",
|
"typescript": "^5.5.4",
|
||||||
"tsup": "^8.3.5",
|
"concurrently": "^8.0.0",
|
||||||
"rimraf": "^6.0.1"
|
"tsup": "^8.3.5",
|
||||||
}
|
"rimraf": "^6.0.1"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
// 关联表选择
|
// 关联表选择
|
||||||
|
|
|
@ -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/**",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,20 +4,38 @@
|
||||||
* @returns 唯一ID字符串
|
* @returns 唯一ID字符串
|
||||||
*/
|
*/
|
||||||
export function generateUniqueId(prefix?: string): string {
|
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'
|
const environmentPart =
|
||||||
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
|
typeof window !== "undefined"
|
||||||
: require('crypto').randomBytes(4).toString('hex');
|
? 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;
|
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";
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export type NonVoid<T> = T extends void ? never : T;
|
Loading…
Reference in New Issue