From 8bd09a302ecb9b7a2ddbfa38150a8eb48cd43341 Mon Sep 17 00:00:00 2001 From: keep <1603421097@qq.com> Date: Wed, 15 Apr 2026 11:13:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加文件上传功能的后端服务和前端组件,包括: - 后端文件上传模块、实体、DTO、配置和数据库迁移 - 前端文件上传页面、组件和服务 - 文件列表展示和管理功能 - 文件类型验证和大小限制 - 用户认证和权限检查 --- .env | 5 + .env.example | 5 + backend/src/app.module.ts | 5 +- backend/src/config/file-upload.config.ts | 21 ++ .../20240320000000_create_files_table.ts | 19 ++ .../controllers/file-upload.controller.ts | 90 +++++++ .../file-upload/dto/file-response.dto.ts | 10 + .../file-upload/entities/file.entity.ts | 11 + .../modules/file-upload/file-upload.module.ts | 21 ++ .../repositories/file.repository.ts | 72 +++++ .../services/file-upload.service.ts | 173 ++++++++++++ frontend/src/components/file-list/index.tsx | 167 ++++++++++++ frontend/src/components/file-upload/index.tsx | 246 ++++++++++++++++++ frontend/src/lib/file-upload.service.ts | 147 +++++++++++ frontend/src/pages/files/index.tsx | 97 +++++++ frontend/src/router.tsx | 5 + 16 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 backend/src/config/file-upload.config.ts create mode 100644 backend/src/migrations/20240320000000_create_files_table.ts create mode 100644 backend/src/modules/file-upload/controllers/file-upload.controller.ts create mode 100644 backend/src/modules/file-upload/dto/file-response.dto.ts create mode 100644 backend/src/modules/file-upload/entities/file.entity.ts create mode 100644 backend/src/modules/file-upload/file-upload.module.ts create mode 100644 backend/src/modules/file-upload/repositories/file.repository.ts create mode 100644 backend/src/modules/file-upload/services/file-upload.service.ts create mode 100644 frontend/src/components/file-list/index.tsx create mode 100644 frontend/src/components/file-upload/index.tsx create mode 100644 frontend/src/lib/file-upload.service.ts create mode 100644 frontend/src/pages/files/index.tsx diff --git a/.env b/.env index a604d77..8f1f122 100644 --- a/.env +++ b/.env @@ -10,3 +10,8 @@ JWT_EXPIRES_IN=7d # Development Settings NODE_ENV=development + +# File Upload Configuration +FILE_UPLOAD_DIR=./uploads +MAX_FILE_SIZE=10485760 +ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain diff --git a/.env.example b/.env.example index a604d77..8f1f122 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ JWT_EXPIRES_IN=7d # Development Settings NODE_ENV=development + +# File Upload Configuration +FILE_UPLOAD_DIR=./uploads +MAX_FILE_SIZE=10485760 +ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0413140..7ec5524 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,20 +5,23 @@ import { MonitoringModule } from './modules/monitoring/monitoring.module'; import { DatabaseModule } from './modules/database/database.module'; import { LoggingModule } from './modules/logging/logging.module'; import { AuthModule } from './modules/auth/auth.module'; +import { FileUploadModule } from './modules/file-upload/file-upload.module'; import { ConditionalAuthGuard } from './modules/auth/guards/conditional-auth.guard'; import { SwaggerModule } from './modules/swagger/swagger.module'; import { authConfig } from './config/auth.config'; +import { fileUploadConfig } from './config/file-upload.config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [authConfig], + load: [authConfig, fileUploadConfig], }), LoggingModule, DatabaseModule, MonitoringModule, AuthModule, + FileUploadModule, SwaggerModule, ], providers: [ diff --git a/backend/src/config/file-upload.config.ts b/backend/src/config/file-upload.config.ts new file mode 100644 index 0000000..fef9fa1 --- /dev/null +++ b/backend/src/config/file-upload.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config'; +import { join } from 'path'; + +export const fileUploadConfig = registerAs('fileUpload', () => ({ + uploadDir: process.env.FILE_UPLOAD_DIR || join(process.cwd(), 'uploads'), + maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024, + allowedMimeTypes: process.env.ALLOWED_MIME_TYPES + ? process.env.ALLOWED_MIME_TYPES.split(',') + : [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], +})); diff --git a/backend/src/migrations/20240320000000_create_files_table.ts b/backend/src/migrations/20240320000000_create_files_table.ts new file mode 100644 index 0000000..d225e0d --- /dev/null +++ b/backend/src/migrations/20240320000000_create_files_table.ts @@ -0,0 +1,19 @@ +import type { Knex } from 'knex'; + +exports.up = async function(knex: Knex): Promise { + await knex.schema.createTable('files', (table) => { + table.string('id').primary(); + table.string('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); + table.string('original_name').notNullable(); + table.string('file_name').notNullable(); + table.string('file_path').notNullable(); + table.bigInteger('file_size').notNullable(); + table.string('mime_type').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); +}; + +exports.down = async function(knex: Knex): Promise { + await knex.schema.dropTable('files'); +}; diff --git a/backend/src/modules/file-upload/controllers/file-upload.controller.ts b/backend/src/modules/file-upload/controllers/file-upload.controller.ts new file mode 100644 index 0000000..6501635 --- /dev/null +++ b/backend/src/modules/file-upload/controllers/file-upload.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + UseInterceptors, + UploadedFile, + Request, + Res, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { FileUploadService } from '../services/file-upload.service'; +import { FileResponseDto } from '../dto/file-response.dto'; + +@Controller('api/files') +export class FileUploadController { + constructor(private readonly fileUploadService: FileUploadService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Request() req: any, + ): Promise { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + return this.fileUploadService.uploadFile( + userId, + file.originalname, + file.mimetype, + file.buffer, + ); + } + + @Get() + async getFiles(@Request() req: any): Promise { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + return this.fileUploadService.getFilesByUser(userId); + } + + @Get(':id') + async downloadFile( + @Param('id') id: string, + @Request() req: any, + @Res() res: Response, + ): Promise { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const { file, stream } = await this.fileUploadService.getFileById(id, userId); + + res.set({ + 'Content-Type': file.mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(file.originalName)}"`, + 'Content-Length': file.fileSize, + }); + + stream.pipe(res); + } + + @Delete(':id') + async deleteFile( + @Param('id') id: string, + @Request() req: any, + ): Promise<{ message: string }> { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + await this.fileUploadService.deleteFile(id, userId); + return { message: 'File deleted successfully' }; + } +} diff --git a/backend/src/modules/file-upload/dto/file-response.dto.ts b/backend/src/modules/file-upload/dto/file-response.dto.ts new file mode 100644 index 0000000..9febd46 --- /dev/null +++ b/backend/src/modules/file-upload/dto/file-response.dto.ts @@ -0,0 +1,10 @@ +export class FileResponseDto { + id: string; + userId: string; + originalName: string; + fileName: string; + fileSize: number; + mimeType: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/src/modules/file-upload/entities/file.entity.ts b/backend/src/modules/file-upload/entities/file.entity.ts new file mode 100644 index 0000000..e7e7adc --- /dev/null +++ b/backend/src/modules/file-upload/entities/file.entity.ts @@ -0,0 +1,11 @@ +export class File { + id: string; + userId: string; + originalName: string; + fileName: string; + filePath: string; + fileSize: number; + mimeType: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/src/modules/file-upload/file-upload.module.ts b/backend/src/modules/file-upload/file-upload.module.ts new file mode 100644 index 0000000..bdb009b --- /dev/null +++ b/backend/src/modules/file-upload/file-upload.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MulterModule } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { FileUploadController } from './controllers/file-upload.controller'; +import { FileUploadService } from './services/file-upload.service'; +import { FileRepository } from './repositories/file.repository'; +import { fileUploadConfig } from '../../config/file-upload.config'; + +@Module({ + imports: [ + ConfigModule.forFeature(fileUploadConfig), + MulterModule.register({ + storage: memoryStorage(), + }), + ], + controllers: [FileUploadController], + providers: [FileUploadService, FileRepository], + exports: [FileUploadService], +}) +export class FileUploadModule {} diff --git a/backend/src/modules/file-upload/repositories/file.repository.ts b/backend/src/modules/file-upload/repositories/file.repository.ts new file mode 100644 index 0000000..cb7c00d --- /dev/null +++ b/backend/src/modules/file-upload/repositories/file.repository.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { ulid } from 'ulid'; +import { DatabaseService } from '../../database/database.service'; +import { File } from '../entities/file.entity'; + +@Injectable() +export class FileRepository { + constructor(private readonly databaseService: DatabaseService) {} + + private get knex() { + return this.databaseService.knex; + } + + async create(fileData: Omit): Promise { + const id = ulid(); + const now = new Date(); + + const [createdFile] = await this.knex('files') + .insert({ + id, + user_id: fileData.userId, + original_name: fileData.originalName, + file_name: fileData.fileName, + file_path: fileData.filePath, + file_size: fileData.fileSize, + mime_type: fileData.mimeType, + created_at: now, + updated_at: now, + }) + .returning('*'); + + return this.mapToEntity(createdFile); + } + + async findById(id: string): Promise { + const file = await this.knex('files') + .where('id', id) + .first(); + + return file ? this.mapToEntity(file) : null; + } + + async findByUserId(userId: string): Promise { + const files = await this.knex('files') + .where('user_id', userId) + .orderBy('created_at', 'desc'); + + return files.map(this.mapToEntity); + } + + async delete(id: string): Promise { + const deletedCount = await this.knex('files') + .where('id', id) + .delete(); + + return deletedCount > 0; + } + + private mapToEntity(row: any): File { + return { + id: row.id, + userId: row.user_id, + originalName: row.original_name, + fileName: row.file_name, + filePath: row.file_path, + fileSize: Number(row.file_size), + mimeType: row.mime_type, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/backend/src/modules/file-upload/services/file-upload.service.ts b/backend/src/modules/file-upload/services/file-upload.service.ts new file mode 100644 index 0000000..b9f63f7 --- /dev/null +++ b/backend/src/modules/file-upload/services/file-upload.service.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { existsSync, mkdirSync, unlinkSync, createReadStream } from 'fs'; +import { join } from 'path'; +import { ulid } from 'ulid'; +import { FileRepository } from '../repositories/file.repository'; +import { File } from '../entities/file.entity'; +import { FileResponseDto } from '../dto/file-response.dto'; + +@Injectable() +export class FileUploadService { + private readonly logger = new Logger(FileUploadService.name); + private readonly uploadDir: string; + private readonly maxFileSize: number; + private readonly allowedMimeTypes: string[]; + + constructor( + private readonly configService: ConfigService, + private readonly fileRepository: FileRepository, + ) { + this.uploadDir = this.configService.get('fileUpload.uploadDir') || join(process.cwd(), 'uploads'); + this.maxFileSize = this.configService.get('fileUpload.maxFileSize') || 10 * 1024 * 1024; + this.allowedMimeTypes = this.configService.get('fileUpload.allowedMimeTypes') || [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + if (!existsSync(this.uploadDir)) { + mkdirSync(this.uploadDir, { recursive: true }); + this.logger.log(`Upload directory created: ${this.uploadDir}`); + } + } + + async uploadFile( + userId: string, + originalName: string, + mimeType: string, + buffer: Buffer, + ): Promise { + this.validateFile(mimeType, buffer.length); + + const fileExtension = this.getFileExtension(originalName, mimeType); + const fileName = `${ulid()}${fileExtension}`; + const filePath = join(this.uploadDir, fileName); + + try { + const fs = await import('fs'); + fs.writeFileSync(filePath, buffer); + + const file = await this.fileRepository.create({ + userId, + originalName, + fileName, + filePath, + fileSize: buffer.length, + mimeType, + }); + + this.logger.log(`File uploaded: ${originalName} (${fileName}) by user ${userId}`); + return this.mapToDto(file); + } catch (error) { + this.logger.error(`Failed to upload file: ${error.message}`, error.stack); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + throw error; + } + } + + async getFilesByUser(userId: string): Promise { + const files = await this.fileRepository.findByUserId(userId); + return files.map(this.mapToDto); + } + + async getFileById(fileId: string, userId: string): Promise<{ file: File; stream: any }> { + const file = await this.fileRepository.findById(fileId); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (file.userId !== userId) { + throw new ForbiddenException('You do not have permission to access this file'); + } + + if (!existsSync(file.filePath)) { + throw new NotFoundException('File not found on disk'); + } + + const stream = createReadStream(file.filePath); + return { file, stream }; + } + + async deleteFile(fileId: string, userId: string): Promise { + const file = await this.fileRepository.findById(fileId); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (file.userId !== userId) { + throw new ForbiddenException('You do not have permission to delete this file'); + } + + try { + if (existsSync(file.filePath)) { + unlinkSync(file.filePath); + } + await this.fileRepository.delete(fileId); + this.logger.log(`File deleted: ${file.originalName} (${file.fileName}) by user ${userId}`); + } catch (error) { + this.logger.error(`Failed to delete file: ${error.message}`, error.stack); + throw error; + } + } + + private validateFile(mimeType: string, fileSize: number): void { + if (!this.allowedMimeTypes.includes(mimeType)) { + throw new BadRequestException( + `File type not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`, + ); + } + + if (fileSize > this.maxFileSize) { + throw new BadRequestException( + `File too large. Maximum size: ${this.maxFileSize / (1024 * 1024)}MB`, + ); + } + } + + private getFileExtension(originalName: string, mimeType: string): string { + const extFromName = originalName.split('.').pop(); + if (extFromName && extFromName.length <= 5) { + return `.${extFromName.toLowerCase()}`; + } + + const mimeToExt: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'application/pdf': '.pdf', + 'text/plain': '.txt', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/vnd.ms-excel': '.xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + }; + + return mimeToExt[mimeType] || ''; + } + + private mapToDto(file: File): FileResponseDto { + return { + id: file.id, + userId: file.userId, + originalName: file.originalName, + fileName: file.fileName, + fileSize: file.fileSize, + mimeType: file.mimeType, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }; + } +} diff --git a/frontend/src/components/file-list/index.tsx b/frontend/src/components/file-list/index.tsx new file mode 100644 index 0000000..0ea0037 --- /dev/null +++ b/frontend/src/components/file-list/index.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from 'react' +import { Download, Trash2, File, Image, FileText, FileSpreadsheet, Loader2 } from 'lucide-react' +import { cn } from 'src/lib/utils' +import { Button } from 'src/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from 'src/components/ui/card' +import { FileUploadService, FileItem } from 'src/lib/file-upload.service' + +interface FileListProps { + onFileDeleted?: () => void +} + +const getFileIcon = (type: string) => { + if (type.startsWith('image/')) return Image + if (type === 'application/pdf') return FileText + if (type.includes('excel') || type.includes('spreadsheet')) return FileSpreadsheet + return File +} + +const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function FileList({ onFileDeleted }: FileListProps) { + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + const fetchFiles = async () => { + try { + setLoading(true) + setError(null) + const data = await FileUploadService.getFiles() + setFiles(data) + } catch (err) { + setError('Failed to load files') + console.error('Failed to fetch files:', err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchFiles() + }, []) + + const handleDownload = async (file: FileItem) => { + try { + await FileUploadService.downloadFile(file.id, file.originalName) + } catch (err) { + console.error('Failed to download file:', err) + setError('Failed to download file') + } + } + + const handleDelete = async (file: FileItem) => { + try { + setDeletingId(file.id) + await FileUploadService.deleteFile(file.id) + setFiles((prev) => prev.filter((f) => f.id !== file.id)) + onFileDeleted?.() + } catch (err) { + console.error('Failed to delete file:', err) + setError('Failed to delete file') + } finally { + setDeletingId(null) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( + + +

{error}

+ +
+
+ ) + } + + if (files.length === 0) { + return ( + + + +

No files uploaded yet

+
+
+ ) + } + + return ( + + + Your Files ({files.length}) + + +
+ {files.map((file) => { + const Icon = getFileIcon(file.mimeType) + const isDeleting = deletingId === file.id + return ( +
+ +
+

{file.originalName}

+

+ {FileUploadService.formatFileSize(file.fileSize)} •{' '} + {formatDate(file.createdAt)} +

+
+
+ + +
+
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/file-upload/index.tsx b/frontend/src/components/file-upload/index.tsx new file mode 100644 index 0000000..2cd8389 --- /dev/null +++ b/frontend/src/components/file-upload/index.tsx @@ -0,0 +1,246 @@ +import React, { useState, useRef, useCallback } from 'react' +import { Upload, X, File, Image, FileText, FileSpreadsheet, AlertCircle } from 'lucide-react' +import { cn } from 'src/lib/utils' +import { Button } from 'src/components/ui/button' +import { Progress } from 'src/components/ui/progress' +import { FileUploadService, FileItem } from 'src/lib/file-upload.service' + +interface FileUploadProps { + onUploadComplete?: (file: FileItem) => void + onUploadError?: (error: Error) => void + maxFiles?: number + maxFileSize?: number + acceptedTypes?: string[] +} + +const MAX_FILE_SIZE = 10 * 1024 * 1024 +const ACCEPTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +] + +const getFileIcon = (type: string) => { + if (type.startsWith('image/')) return Image + if (type === 'application/pdf') return FileText + if (type.includes('excel') || type.includes('spreadsheet')) return FileSpreadsheet + return File +} + +interface UploadingFile { + id: string + file: File + progress: number + status: 'uploading' | 'success' | 'error' + error?: string +} + +export function FileUpload({ + onUploadComplete, + onUploadError, + maxFiles = 10, + maxFileSize = MAX_FILE_SIZE, + acceptedTypes = ACCEPTED_TYPES, +}: FileUploadProps) { + const [isDragging, setIsDragging] = useState(false) + const [uploadingFiles, setUploadingFiles] = useState([]) + const fileInputRef = useRef(null) + + const validateFile = (file: File): { valid: boolean; error?: string } => { + if (file.size > maxFileSize) { + return { + valid: false, + error: `File too large. Maximum size: ${FileUploadService.formatFileSize(maxFileSize)}`, + } + } + + if (!acceptedTypes.includes(file.type) && file.type !== '') { + return { + valid: false, + error: `File type not allowed. Allowed types: images, PDF, text, Word, Excel`, + } + } + + return { valid: true } + } + + const handleFiles = useCallback( + async (files: FileList | File[]) => { + const fileArray = Array.from(files) + const remainingSlots = maxFiles - uploadingFiles.filter(f => f.status === 'uploading').length + + if (fileArray.length > remainingSlots) { + onUploadError?.(new Error(`Maximum ${maxFiles} files can be uploaded at once`)) + return + } + + const newUploadingFiles: UploadingFile[] = fileArray.map((file) => { + const validation = validateFile(file) + return { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + file, + progress: 0, + status: validation.valid ? 'uploading' : 'error', + error: validation.error, + } + }) + + setUploadingFiles((prev) => [...prev, ...newUploadingFiles]) + + for (const uploadingFile of newUploadingFiles) { + if (uploadingFile.status === 'error') continue + + try { + const result = await FileUploadService.uploadFile(uploadingFile.file, (progress) => { + setUploadingFiles((prev) => + prev.map((f) => + f.id === uploadingFile.id ? { ...f, progress } : f + ) + ) + }) + + setUploadingFiles((prev) => + prev.map((f) => + f.id === uploadingFile.id ? { ...f, status: 'success', progress: 100 } : f + ) + ) + + onUploadComplete?.(result) + } catch (error) { + setUploadingFiles((prev) => + prev.map((f) => + f.id === uploadingFile.id + ? { ...f, status: 'error', error: (error as Error).message } + : f + ) + ) + onUploadError?.(error as Error) + } + } + + setTimeout(() => { + setUploadingFiles((prev) => prev.filter((f) => f.status === 'uploading')) + }, 3000) + }, + [maxFiles, uploadingFiles, onUploadComplete, onUploadError] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + if (e.dataTransfer.files) { + handleFiles(e.dataTransfer.files) + } + }, + [handleFiles] + ) + + const handleClick = () => { + fileInputRef.current?.click() + } + + const handleInputChange = (e: React.ChangeEvent) => { + if (e.target.files) { + handleFiles(e.target.files) + } + } + + const removeFile = (id: string) => { + setUploadingFiles((prev) => prev.filter((f) => f.id !== id)) + } + + const acceptString = acceptedTypes.join(',') + + return ( +
+
+ + +
+

Drag and drop files here, or click to select

+

+ Max file size: {FileUploadService.formatFileSize(maxFileSize)} +

+
+
+ + {uploadingFiles.length > 0 && ( +
+ {uploadingFiles.map((uploadingFile) => { + const Icon = getFileIcon(uploadingFile.file.type) + return ( +
+ +
+

{uploadingFile.file.name}

+

+ {FileUploadService.formatFileSize(uploadingFile.file.size)} +

+ {uploadingFile.status === 'uploading' && ( + + )} + {uploadingFile.status === 'error' && ( +
+ + {uploadingFile.error} +
+ )} + {uploadingFile.status === 'success' && ( +

Upload successful

+ )} +
+ +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/file-upload.service.ts b/frontend/src/lib/file-upload.service.ts new file mode 100644 index 0000000..5ea60f1 --- /dev/null +++ b/frontend/src/lib/file-upload.service.ts @@ -0,0 +1,147 @@ +import { env } from './env' +import { AuthService } from './auth.service' + +export interface FileItem { + id: string + userId: string + originalName: string + fileName: string + fileSize: number + mimeType: string + createdAt: string + updatedAt: string +} + +export class FileUploadService { + private static getHeaders(): Record { + const token = AuthService.getToken() + const headers: Record = {} + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + return headers + } + + static async uploadFile(file: File, onProgress?: (progress: number) => void): Promise { + const formData = new FormData() + formData.append('file', file) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable && onProgress) { + const progress = Math.round((event.loaded / event.total) * 100) + onProgress(progress) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText) + resolve(response) + } catch (error) { + reject(new Error('Failed to parse response')) + } + } else { + try { + const errorResponse = JSON.parse(xhr.responseText) + reject(new Error(errorResponse.message || 'Upload failed')) + } catch { + reject(new Error(`Upload failed with status: ${xhr.status}`)) + } + } + }) + + xhr.addEventListener('error', () => { + reject(new Error('Network error')) + }) + + xhr.addEventListener('abort', () => { + reject(new Error('Upload aborted')) + }) + + xhr.open('POST', `${env.VITE_API_URL}/api/files/upload`) + + const token = AuthService.getToken() + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + } + + xhr.send(formData) + }) + } + + static async getFiles(): Promise { + const response = await fetch(`${env.VITE_API_URL}/api/files`, { + headers: this.getHeaders(), + }) + + if (!response.ok) { + throw new Error('Failed to fetch files') + } + + return response.json() + } + + static async downloadFile(fileId: string, fileName: string): Promise { + const response = await fetch(`${env.VITE_API_URL}/api/files/${fileId}`, { + headers: this.getHeaders(), + }) + + if (!response.ok) { + throw new Error('Failed to download file') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } + + static async deleteFile(fileId: string): Promise { + const response = await fetch(`${env.VITE_API_URL}/api/files/${fileId}`, { + method: 'DELETE', + headers: this.getHeaders(), + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + static formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + static getFileIcon(mimeType: string): string { + if (mimeType.startsWith('image/')) { + return 'image' + } + if (mimeType === 'application/pdf') { + return 'pdf' + } + if (mimeType.includes('word') || mimeType.includes('document')) { + return 'document' + } + if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) { + return 'spreadsheet' + } + if (mimeType === 'text/plain') { + return 'text' + } + return 'file' + } +} diff --git a/frontend/src/pages/files/index.tsx b/frontend/src/pages/files/index.tsx new file mode 100644 index 0000000..15af208 --- /dev/null +++ b/frontend/src/pages/files/index.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react' +import { Helmet } from 'react-helmet' +import { useTranslation } from 'react-i18next' +import { Upload, FileText } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from 'src/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from 'src/components/ui/tabs' +import { FileUpload } from 'src/components/file-upload' +import { FileList } from 'src/components/file-list' +import { FileItem } from 'src/lib/file-upload.service' +import { useToast } from 'src/hooks/use-toast' + +export default function FilesPage() { + const { t } = useTranslation('translation') + const { toast } = useToast() + const [activeTab, setActiveTab] = useState('upload') + const [refreshKey, setRefreshKey] = useState(0) + + const handleUploadComplete = (file: FileItem) => { + toast({ + title: 'Upload successful', + description: `${file.originalName} has been uploaded`, + }) + } + + const handleUploadError = (error: Error) => { + toast({ + title: 'Upload failed', + description: error.message, + variant: 'destructive', + }) + } + + const handleFileDeleted = () => { + setRefreshKey((prev) => prev + 1) + toast({ + title: 'File deleted', + description: 'The file has been deleted successfully', + }) + } + + return ( + <> + + {t('title')} - Files + +
+
+

File Management

+

+ Upload, download, and manage your files securely +

+
+ + + + + + Upload Files + + + + My Files + + + + + + + Upload New Files + + + +
+

Upload Guidelines

+
    +
  • • Maximum file size: 10MB per file
  • +
  • • Supported file types: Images (JPEG, PNG, GIF, WebP), PDF, Text, Word, Excel
  • +
  • • Maximum 10 files can be uploaded at once
  • +
  • • All uploads require user authentication
  • +
+
+
+
+
+ + + + +
+
+ + ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 17e3a81..b7d3cb5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -3,6 +3,7 @@ import { createHashRouter, RouteObject } from 'react-router-dom' import ErrorPage from './components/error-page' import { getDefaultLayout } from './components/layout' import HomePage from './pages/home' +import FilesPage from './pages/files' import { LoginPage } from './pages/auth/login' import { SignupPage } from './pages/auth/signup' import { isAuthEnabled } from './lib/auth-config' @@ -12,6 +13,10 @@ const baseRoutes: RouteObject[] = [ path: '/', Component: HomePage, }, + { + path: '/files', + Component: FilesPage, + }, ] const authRoutes: RouteObject[] = [ From 347579b5c74ff47bc7c16dafb2d5951b99615370 Mon Sep 17 00:00:00 2001 From: keep <1603421097@qq.com> Date: Wed, 15 Apr 2026 11:34:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0):?= =?UTF-8?q?=20=E9=87=8D=E6=9E=84=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取用户ID获取逻辑到独立方法,支持配置开关认证 - 添加匿名用户ID作为默认值,允许未认证用户上传文件 - 新增@types/multer依赖以支持文件上传类型检查 --- backend/package.json | 3 +- .../controllers/file-upload.controller.ts | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/backend/package.json b/backend/package.json index aae191a..7fe6f50 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,8 +27,8 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^8.1.0", - "class-validator": "^0.14.1", "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "knex": "^3.1.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -44,6 +44,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/multer": "^2.1.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", diff --git a/backend/src/modules/file-upload/controllers/file-upload.controller.ts b/backend/src/modules/file-upload/controllers/file-upload.controller.ts index 6501635..11b209d 100644 --- a/backend/src/modules/file-upload/controllers/file-upload.controller.ts +++ b/backend/src/modules/file-upload/controllers/file-upload.controller.ts @@ -12,12 +12,29 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; import { FileUploadService } from '../services/file-upload.service'; import { FileResponseDto } from '../dto/file-response.dto'; +const DEFAULT_USER_ID = 'anonymous-user-id'; + @Controller('api/files') export class FileUploadController { - constructor(private readonly fileUploadService: FileUploadService) {} + constructor( + private readonly fileUploadService: FileUploadService, + private readonly configService: ConfigService, + ) {} + + private getUserId(req: any): string { + const authEnabled = this.configService.get('auth.enabled'); + const userId = req.user?.id; + + if (authEnabled && !userId) { + throw new BadRequestException('User not authenticated'); + } + + return userId || DEFAULT_USER_ID; + } @Post('upload') @UseInterceptors(FileInterceptor('file')) @@ -29,10 +46,7 @@ export class FileUploadController { throw new BadRequestException('No file uploaded'); } - const userId = req.user?.id; - if (!userId) { - throw new BadRequestException('User not authenticated'); - } + const userId = this.getUserId(req); return this.fileUploadService.uploadFile( userId, @@ -44,11 +58,7 @@ export class FileUploadController { @Get() async getFiles(@Request() req: any): Promise { - const userId = req.user?.id; - if (!userId) { - throw new BadRequestException('User not authenticated'); - } - + const userId = this.getUserId(req); return this.fileUploadService.getFilesByUser(userId); } @@ -58,11 +68,7 @@ export class FileUploadController { @Request() req: any, @Res() res: Response, ): Promise { - const userId = req.user?.id; - if (!userId) { - throw new BadRequestException('User not authenticated'); - } - + const userId = this.getUserId(req); const { file, stream } = await this.fileUploadService.getFileById(id, userId); res.set({ @@ -79,11 +85,7 @@ export class FileUploadController { @Param('id') id: string, @Request() req: any, ): Promise<{ message: string }> { - const userId = req.user?.id; - if (!userId) { - throw new BadRequestException('User not authenticated'); - } - + const userId = this.getUserId(req); await this.fileUploadService.deleteFile(id, userId); return { message: 'File deleted successfully' }; }