diff --git a/apps/api/.env.development b/apps/api/.env.development index fbd7172..ada2221 100644 --- a/apps/api/.env.development +++ b/apps/api/.env.development @@ -11,6 +11,9 @@ DB_DATABASE=vest_mind_dev JWT_SECRET=vest_thinking_key JWT_EXPIRES_IN=7d +# 资源上传配置 +STORAGE_TYPE=local + ADMIN_USERNAME=joey ADMIN_PASSWORD=joey5628 ADMIN_EMAIL=zhangyi5628@126.com diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b29f273..4523e53 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { CoreModule } from './core/core.module'; import { BrokerModule } from './modules/broker/broker.module'; import { UserModule } from './modules/user/user.module'; import { AuthModule } from './modules/auth/auth.module'; +import { StorageModule } from './modules/storage/storage.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { AuthModule } from './modules/auth/auth.module'; BrokerModule, UserModule, AuthModule, + StorageModule, ], controllers: [], providers: [], diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b04940a..f24d6be 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,14 +6,23 @@ import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import bodyParser from 'body-parser'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; async function bootstrap() { const configService = new ConfigService(); // const isProduction = configService.get('NODE_ENV') === 'production'; - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(AppModule, { bodyParser: false, // 禁用默认 bodyParser,使用自定义配置 }); + // 配置静态文件服务 + const storagePath = + configService.get('STORAGE_PATH') || './uploads'; + app.useStaticAssets(join(process.cwd(), storagePath), { + prefix: '/uploads/', + }); + // 安全头设置(必须在其他中间件之前) app.use(helmet()); diff --git a/apps/api/src/modules/broker/broker.module.ts b/apps/api/src/modules/broker/broker.module.ts index f1ddb9f..8cf1bfc 100644 --- a/apps/api/src/modules/broker/broker.module.ts +++ b/apps/api/src/modules/broker/broker.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BrokerService } from './broker.service'; import { BrokerController } from './broker.controller'; import { Broker } from './broker.entity'; +import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [TypeOrmModule.forFeature([Broker])], + imports: [TypeOrmModule.forFeature([Broker]), StorageModule], controllers: [BrokerController], providers: [BrokerService], exports: [BrokerService], diff --git a/apps/api/src/modules/broker/broker.service.ts b/apps/api/src/modules/broker/broker.service.ts index 9b2ba28..bc8b40c 100644 --- a/apps/api/src/modules/broker/broker.service.ts +++ b/apps/api/src/modules/broker/broker.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere } from 'typeorm'; @@ -11,12 +12,16 @@ import { UpdateBrokerDto } from './dto/update-broker.dto'; import { QueryBrokerDto } from './dto/query-broker.dto'; import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; import { PaginationInfo } from '@/common/dto/pagination.dto'; +import { StorageService } from '../storage/storage.service'; @Injectable() export class BrokerService { + private readonly logger = new Logger(BrokerService.name); + constructor( @InjectRepository(Broker) private readonly brokerRepository: Repository, + private readonly storageService: StorageService, ) {} /** @@ -290,9 +295,30 @@ export class BrokerService { /** * 删除 broker + * 同时删除券商Logo图片 */ async remove(id: number): Promise { const broker = await this.findOne(id); + + // 删除券商Logo图片 + if (broker.brokerImage) { + try { + const imagePath = this.storageService.extractStoragePath( + broker.brokerImage, + ); + if (imagePath) { + await this.storageService.delete(imagePath); + this.logger.log(`已删除券商Logo: ${imagePath}`); + } + } catch (error) { + // 图片删除失败不影响券商删除操作 + this.logger.warn( + `删除券商Logo失败: ${broker.brokerImage}`, + error, + ); + } + } + await this.brokerRepository.remove(broker); } } diff --git a/apps/api/src/modules/storage/dto/upload-file.dto.ts b/apps/api/src/modules/storage/dto/upload-file.dto.ts new file mode 100644 index 0000000..f633978 --- /dev/null +++ b/apps/api/src/modules/storage/dto/upload-file.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsIn } from 'class-validator'; + +export class UploadFileDto { + @ApiPropertyOptional({ + description: '存储文件夹', + example: 'broker', + enum: ['broker', 'user', 'temp'], + }) + @IsOptional() + @IsString() + @IsIn(['broker', 'user', 'temp']) + folder?: string; + + @ApiPropertyOptional({ + description: '自定义文件名', + example: 'custom-filename.jpg', + }) + @IsOptional() + @IsString() + filename?: string; +} diff --git a/apps/api/src/modules/storage/interfaces/storage-provider.interface.ts b/apps/api/src/modules/storage/interfaces/storage-provider.interface.ts new file mode 100644 index 0000000..7c04863 --- /dev/null +++ b/apps/api/src/modules/storage/interfaces/storage-provider.interface.ts @@ -0,0 +1,60 @@ +/** + * 文件上传对象类型 + */ +export interface FileUpload { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +/** + * 存储提供者接口 + * 所有存储实现都必须实现此接口,方便切换不同的存储方案 + */ +export interface UploadResult { + path: string; // 存储路径(相对路径或完整路径) + url: string; // 访问URL + filename: string; // 文件名 + size: number; // 文件大小(字节) + mimetype: string; // MIME类型 +} + +export interface UploadOptions { + folder?: string; // 存储文件夹,如 'broker', 'user' 等 + filename?: string; // 自定义文件名 + maxSize?: number; // 最大文件大小(字节) + allowedMimeTypes?: string[]; // 允许的MIME类型 +} + +export interface IStorageProvider { + /** + * 上传文件 + * @param file 文件对象 + * @param options 上传选项 + * @returns 上传结果 + */ + upload(file: FileUpload, options?: UploadOptions): Promise; + + /** + * 删除文件 + * @param path 文件路径 + */ + delete(path: string): Promise; + + /** + * 获取文件的访问URL + * @param path 文件路径 + * @returns 访问URL + */ + getUrl(path: string): string; + + /** + * 检查文件是否存在 + * @param path 文件路径 + * @returns 是否存在 + */ + exists(path: string): Promise; +} diff --git a/apps/api/src/modules/storage/providers/local-storage.provider.ts b/apps/api/src/modules/storage/providers/local-storage.provider.ts new file mode 100644 index 0000000..30cb440 --- /dev/null +++ b/apps/api/src/modules/storage/providers/local-storage.provider.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { + IStorageProvider, + UploadResult, + UploadOptions, + FileUpload, +} from '../interfaces/storage-provider.interface'; + +/** + * 本地存储提供者 + * 将文件存储在服务器本地目录 + */ +@Injectable() +export class LocalStorageProvider implements IStorageProvider { + private readonly logger = new Logger(LocalStorageProvider.name); + private readonly basePath: string; + private readonly baseUrl: string; + + constructor(private readonly configService: ConfigService) { + // 从环境变量读取配置 + this.basePath = + this.configService.get('STORAGE_PATH') || './uploads'; + this.baseUrl = + this.configService.get('STORAGE_BASE_URL') || + 'http://localhost:3200/uploads'; + } + + /** + * 上传文件到本地存储 + */ + async upload( + file: FileUpload, + options?: UploadOptions, + ): Promise { + try { + // 验证文件大小 + if (options?.maxSize && file.size > options.maxSize) { + throw new Error( + `文件大小超过限制 ${options.maxSize / 1024 / 1024}MB`, + ); + } + + // 验证文件类型 + if ( + options?.allowedMimeTypes && + !options.allowedMimeTypes.includes(file.mimetype) + ) { + throw new Error( + `不支持的文件类型,允许的类型:${options.allowedMimeTypes.join(', ')}`, + ); + } + + // 确定存储文件夹 + const folder = options?.folder || 'temp'; + const uploadDir = path.join(this.basePath, folder); + + // 确保目录存在 + await fs.mkdir(uploadDir, { recursive: true }); + + // 生成文件名 + const filename = this.generateFilename(file, options?.filename); + const filePath = path.join(uploadDir, filename); + const relativePath = path + .join(folder, filename) + .replace(/\\/g, '/'); + + // 保存文件 + await fs.writeFile(filePath, file.buffer); + + // 生成访问URL + const url = `${this.baseUrl}/${relativePath}`; + + this.logger.log(`文件上传成功: ${relativePath}`); + + return { + path: relativePath, + url, + filename, + size: file.size, + mimetype: file.mimetype, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : '未知错误'; + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error(`文件上传失败: ${errorMessage}`, errorStack); + throw error; + } + } + + /** + * 删除文件 + */ + async delete(filePath: string): Promise { + try { + // filePath 是相对路径(如 broker/filename.jpg) + const fullPath = path.join(this.basePath, filePath); + + // 安全检查:确保文件路径在 basePath 内,防止路径遍历攻击 + const resolvedPath = path.resolve(fullPath); + const resolvedBasePath = path.resolve(this.basePath); + if (!resolvedPath.startsWith(resolvedBasePath)) { + throw new Error('非法文件路径'); + } + + // 检查文件是否存在 + try { + await fs.access(resolvedPath); + } catch { + this.logger.warn(`文件不存在: ${resolvedPath}`); + return; + } + + await fs.unlink(resolvedPath); + this.logger.log(`文件删除成功: ${resolvedPath}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : '未知错误'; + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error(`文件删除失败: ${errorMessage}`, errorStack); + throw error; + } + } + + /** + * 获取文件的访问URL + */ + getUrl(filePath: string): string { + const relativePath = filePath.startsWith(this.basePath) + ? filePath.replace(this.basePath, '').replace(/^[/\\]/, '') + : filePath; + return `${this.baseUrl}/${relativePath}`.replace(/\/+/g, '/'); + } + + /** + * 检查文件是否存在 + */ + async exists(filePath: string): Promise { + try { + const fullPath = filePath.startsWith(this.basePath) + ? filePath + : path.join(this.basePath, filePath); + await fs.access(fullPath); + return true; + } catch { + return false; + } + } + + /** + * 生成文件名 + * 格式: {timestamp}-{random}-{originalname} + */ + private generateFilename(file: FileUpload, customName?: string): string { + if (customName) { + return customName; + } + + const timestamp = Date.now(); + const random = crypto.randomBytes(8).toString('hex'); + const ext = path.extname(file.originalname); + const nameWithoutExt = path.basename(file.originalname, ext); + + // 清理文件名,移除特殊字符 + const cleanName = nameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + + return `${timestamp}-${random}-${cleanName}${ext}`; + } +} diff --git a/apps/api/src/modules/storage/storage.controller.ts b/apps/api/src/modules/storage/storage.controller.ts new file mode 100644 index 0000000..ba694b9 --- /dev/null +++ b/apps/api/src/modules/storage/storage.controller.ts @@ -0,0 +1,244 @@ +import { + Controller, + Post, + Delete, + Param, + UseInterceptors, + UploadedFile, + Body, + HttpCode, + HttpStatus, + UseGuards, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiConsumes, + ApiBody, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { StorageService } from './storage.service'; +import { UploadFileDto } from './dto/upload-file.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import type { FileUpload } from './interfaces/storage-provider.interface'; + +@ApiTags('storage') +@Controller('storage') +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + /** + * 管理员上传文件(需要鉴权) + * 用于上传券商Logo等基础数据 + */ + @Post('upload') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: '管理员上传文件', + description: '上传单个文件,需要管理员权限,用于上传券商Logo等基础数据', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '要上传的文件', + }, + folder: { + type: 'string', + enum: ['broker', 'user', 'temp'], + description: '存储文件夹', + }, + filename: { + type: 'string', + description: '自定义文件名', + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: '上传成功', + schema: { + type: 'object', + properties: { + path: { + type: 'string', + example: 'broker/1234567890-abcdef-broker-logo.jpg', + }, + url: { + type: 'string', + example: + 'http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg', + }, + filename: { + type: 'string', + example: '1234567890-abcdef-broker-logo.jpg', + }, + size: { + type: 'number', + example: 102400, + }, + mimetype: { + type: 'string', + example: 'image/jpeg', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: '文件格式或大小不符合要求' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '权限不足' }) + async uploadAdmin( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB + new FileTypeValidator({ + fileType: /^image\/(jpeg|jpg|png|gif|webp)$/, + }), + ], + }), + ) + file: FileUpload, + @Body() uploadDto: UploadFileDto, + ) { + const options = { + folder: uploadDto.folder || 'temp', + maxSize: 5 * 1024 * 1024, // 5MB + allowedMimeTypes: [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + ], + filename: uploadDto.filename, + }; + + return await this.storageService.upload(file, options); + } + + /** + * 用户上传头像(不需要鉴权) + * 用于用户注册或更新头像 + */ + @Post('upload/avatar') + @HttpCode(HttpStatus.OK) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: '上传用户头像', + description: '上传用户头像,不需要鉴权,用于用户注册或更新头像', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '要上传的头像文件', + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: '上传成功', + schema: { + type: 'object', + properties: { + path: { + type: 'string', + example: 'user/1234567890-abcdef-avatar.jpg', + }, + url: { + type: 'string', + example: + 'http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg', + }, + filename: { + type: 'string', + example: '1234567890-abcdef-avatar.jpg', + }, + size: { + type: 'number', + example: 102400, + }, + mimetype: { + type: 'string', + example: 'image/jpeg', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: '文件格式或大小不符合要求' }) + async uploadAvatar( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB + new FileTypeValidator({ + fileType: /^image\/(jpeg|jpg|png|gif|webp)$/, + }), + ], + }), + ) + file: FileUpload, + ) { + const options = { + folder: 'user', // 用户头像固定存储在 user 文件夹 + maxSize: 2 * 1024 * 1024, // 2MB(头像文件限制更小) + allowedMimeTypes: [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + ], + }; + + return await this.storageService.upload(file, options); + } + + /** + * 删除文件(需要管理员权限) + */ + @Delete(':path(*)') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @ApiOperation({ + summary: '删除文件', + description: '根据文件路径删除文件,需要管理员权限', + }) + @ApiParam({ + name: 'path', + description: '文件路径(相对路径)', + example: 'broker/1234567890-abcdef-broker-logo.jpg', + }) + @ApiResponse({ status: 204, description: '删除成功' }) + @ApiResponse({ status: 404, description: '文件不存在' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '权限不足' }) + async delete(@Param('path') filePath: string) { + await this.storageService.delete(filePath); + } +} diff --git a/apps/api/src/modules/storage/storage.module.ts b/apps/api/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..9df8d4a --- /dev/null +++ b/apps/api/src/modules/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StorageController } from './storage.controller'; +import { StorageService } from './storage.service'; + +@Module({ + controllers: [StorageController], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/api/src/modules/storage/storage.service.ts b/apps/api/src/modules/storage/storage.service.ts new file mode 100644 index 0000000..206065f --- /dev/null +++ b/apps/api/src/modules/storage/storage.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { LocalStorageProvider } from './providers/local-storage.provider'; +import type { + IStorageProvider, + UploadResult, + UploadOptions, + FileUpload, +} from './interfaces/storage-provider.interface'; + +/** + * 存储服务 + * 根据配置选择不同的存储提供者 + */ +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly provider: IStorageProvider; + + constructor(private readonly configService: ConfigService) { + // 根据环境变量选择存储提供者 + const storageType = + // 是的,STORAGE_TYPE 应配置在 .env 文件中,否则将默认使用 'local' + this.configService.get('STORAGE_TYPE') || 'local'; + + this.logger.log(`使用存储类型: ${storageType}`); + + switch (storageType) { + case 'local': + this.provider = new LocalStorageProvider(configService); + break; + // 未来可以添加其他存储提供者 + // case 'qiniu': + // this.provider = new QiniuStorageProvider(configService); + // break; + default: + this.provider = new LocalStorageProvider(configService); + this.logger.warn( + `未知的存储类型 ${storageType},使用默认本地存储`, + ); + } + } + + /** + * 上传文件 + */ + async upload( + file: FileUpload, + options?: UploadOptions, + ): Promise { + return this.provider.upload(file, options); + } + + /** + * 删除文件 + */ + async delete(path: string): Promise { + return this.provider.delete(path); + } + + /** + * 获取文件URL + */ + getUrl(path: string): string { + return this.provider.getUrl(path); + } + + /** + * 检查文件是否存在 + */ + async exists(path: string): Promise { + return this.provider.exists(path); + } + + /** + * 从URL中提取存储路径 + * 例如: http://localhost:3200/uploads/broker/filename.jpg -> broker/filename.jpg + * @param url 完整的文件访问URL + * @returns 存储路径(相对路径),如果无法解析则返回null + */ + extractStoragePath(url: string): string | null { + try { + // 尝试从URL中提取路径 + // 格式: http://domain/uploads/folder/filename.ext + const urlObj = new URL(url); + const pathname = urlObj.pathname; + + // 移除 /uploads/ 前缀,获取相对路径 + const uploadsPrefix = '/uploads/'; + if (pathname.startsWith(uploadsPrefix)) { + return pathname.substring(uploadsPrefix.length); + } + + // 如果不是标准格式,尝试直接使用路径名(去掉开头的 /) + return pathname.startsWith('/') ? pathname.substring(1) : pathname; + } catch (error) { + // 如果URL格式不正确,返回null + this.logger.warn(`无法解析URL: ${url}`, error); + return null; + } + } +} diff --git a/apps/api/src/modules/user/user.module.ts b/apps/api/src/modules/user/user.module.ts index 9568abc..6d33664 100644 --- a/apps/api/src/modules/user/user.module.ts +++ b/apps/api/src/modules/user/user.module.ts @@ -4,9 +4,10 @@ import { UserService } from './user.service'; import { UserController } from './user.controller'; import { UserSeeder } from './user.seeder'; import { User } from './user.entity'; +import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), StorageModule], controllers: [UserController], providers: [UserService, UserSeeder], exports: [UserService], diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index f6f6645..be82648 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ConflictException, BadRequestException, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere } from 'typeorm'; @@ -13,12 +14,16 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { QueryUserDto } from './dto/query-user.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; import { PaginationInfo } from '@/common/dto/pagination.dto'; +import { StorageService } from '../storage/storage.service'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly storageService: StorageService, ) {} /** @@ -315,9 +320,27 @@ export class UserService { /** * 删除用户(软删除,更新状态为 deleted) + * 同时删除用户头像图片 */ async remove(id: number): Promise { const user = await this.findOneById(id); + + // 删除用户头像图片 + if (user.avatarUrl) { + try { + const imagePath = this.storageService.extractStoragePath( + user.avatarUrl, + ); + if (imagePath) { + await this.storageService.delete(imagePath); + this.logger.log(`已删除用户头像: ${imagePath}`); + } + } catch (error) { + // 图片删除失败不影响用户删除操作 + this.logger.warn(`删除用户头像失败: ${user.avatarUrl}`, error); + } + } + user.status = 'deleted'; await this.userRepository.save(user); } diff --git a/apps/web/src/pages/broker/BrokerFormModal.tsx b/apps/web/src/pages/broker/BrokerFormModal.tsx index 06f7875..df3980c 100644 --- a/apps/web/src/pages/broker/BrokerFormModal.tsx +++ b/apps/web/src/pages/broker/BrokerFormModal.tsx @@ -1,10 +1,12 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Modal, Form, Input, Select, Upload, message } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; -import type { UploadProps } from 'antd'; +import { PlusOutlined, LoadingOutlined } from '@ant-design/icons'; +import type { UploadProps, UploadFile } from 'antd'; import { brokerService } from '@/services/broker'; +import { storageService } from '@/services/storage'; import type { Broker, CreateBrokerRequest } from '@/types/broker'; import { REGION_OPTIONS } from '@/types/broker'; +import type { RcFile } from 'antd/es/upload'; const { Option } = Select; @@ -17,6 +19,8 @@ interface BrokerFormModalProps { const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => { const [form] = Form.useForm(); + const [uploading, setUploading] = useState(false); + const [fileList, setFileList] = useState([]); const isEdit = !!editingBroker; // 当编辑数据变化时,更新表单 @@ -29,45 +33,90 @@ const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: Broker region: editingBroker.region, brokerImage: editingBroker.brokerImage, }); + // 如果有图片,设置文件列表 + if (editingBroker.brokerImage) { + setFileList([ + { + uid: '-1', + name: 'broker-logo', + status: 'done', + url: editingBroker.brokerImage, + }, + ]); + } else { + setFileList([]); + } } else { form.resetFields(); + setFileList([]); } } }, [visible, editingBroker, form]); - // 图片上传配置(仅UI,不上传) + // 自定义上传函数 + const customRequest: UploadProps['customRequest'] = async (options) => { + const { file, onSuccess, onError } = options; + setUploading(true); + + try { + const uploadFile = file as RcFile; + const response = await storageService.uploadFile({ + file: uploadFile, + folder: 'broker', + }); + + // 更新表单字段 + form.setFieldValue('brokerImage', response.url); + + // 更新文件列表 + setFileList([ + { + uid: response.path, + name: response.filename, + status: 'done', + url: response.url, + }, + ]); + + message.success('图片上传成功'); + onSuccess?.(response); + } catch (error: any) { + message.error(error.message || '图片上传失败'); + onError?.(error); + } finally { + setUploading(false); + } + }; + + // 图片上传配置 const uploadProps: UploadProps = { name: 'file', listType: 'picture-card', maxCount: 1, - beforeUpload: () => { - // 阻止自动上传 - return false; - }, - onChange: (info) => { - if (info.file.status === 'done') { - message.success(`${info.file.name} 文件上传成功`); - } else if (info.file.status === 'error') { - message.error(`${info.file.name} 文件上传失败`); + fileList, + accept: 'image/*', + customRequest, + beforeUpload: (file) => { + const isImage = file.type.startsWith('image/'); + if (!isImage) { + message.error('只能上传图片文件!'); + return false; } + const isLt5M = file.size / 1024 / 1024 < 5; + if (!isLt5M) { + message.error('图片大小不能超过 5MB!'); + return false; + } + return true; }, onRemove: () => { + setFileList([]); form.setFieldValue('brokerImage', ''); + return true; + }, + onChange: ({ fileList: newFileList }) => { + setFileList(newFileList); }, - }; - - // 处理图片变化(仅UI,实际应该上传后获取URL) - const handleImageChange = (info: any) => { - // 这里只做UI处理,实际应该调用上传接口获取图片URL - // 临时处理:使用本地预览 - if (info.file) { - const reader = new FileReader(); - reader.onload = () => { - // 实际应该调用上传接口,这里只是示例 - // form.setFieldValue('brokerImage', uploadUrl); - }; - reader.readAsDataURL(info.file); - } }; // 提交表单 @@ -148,20 +197,27 @@ const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: Broker - + - - -
- -
上传
-
+ + + {fileList.length >= 1 ? null : ( +
+ {uploading ? : } +
上传
+
+ )}
- 注意:上传功能仅做UI演示,实际需要调用上传接口获取图片URL + 支持 JPG、PNG、GIF、WebP 格式,大小不超过 5MB
diff --git a/apps/web/src/services/storage.ts b/apps/web/src/services/storage.ts new file mode 100644 index 0000000..b660e6b --- /dev/null +++ b/apps/web/src/services/storage.ts @@ -0,0 +1,67 @@ +import { api } from './api'; + +export interface UploadFileParams { + file: File; + folder?: 'broker' | 'user' | 'temp'; + filename?: string; +} + +export interface UploadFileResponse { + path: string; + url: string; + filename: string; + size: number; + mimetype: string; +} + +/** + * 存储服务 + */ +class StorageService { + /** + * 管理员上传文件(需要鉴权) + * 用于上传券商Logo等基础数据 + */ + async uploadFile(params: UploadFileParams): Promise { + const formData = new FormData(); + formData.append('file', params.file); + + if (params.folder) { + formData.append('folder', params.folder); + } + + if (params.filename) { + formData.append('filename', params.filename); + } + + return await api.post('/storage/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + } + + /** + * 上传用户头像(不需要鉴权) + * 用于用户注册或更新头像 + */ + async uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + return await api.post('/storage/upload/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + } + + /** + * 删除文件(需要管理员权限) + */ + async deleteFile(path: string): Promise { + await api.delete(`/storage/${path}`); + } +} + +export const storageService = new StorageService();