Angular 13表单多文件上传及选后删除功能实现问题求解
Angular 13表单多文件上传功能问题排查及实现方案
现有代码核心问题
- 逻辑仅支持单文件:虽然HTML的input加了
multiple属性,但onSelectfileFile方法只取files[0]处理,多文件选中后其余文件直接被忽略 - 数据结构不匹配:原有逻辑用单个字符串变量
fileFileName、fileFileToken存储文件信息,没有数组结构承载多文件的名称、上传状态、服务端返回标识 - 上传队列逻辑错误:每次触发文件选择都会调用
clearQueue()清空历史队列,多次选择文件时之前选中的内容会被全部清除 - 视图渲染不支持多文件:现有模板仅判断单个
fileFileName值渲染单条文件记录,没有多文件遍历展示逻辑,也不支持单条删除待上传/已存文件 - 删除逻辑不兼容多文件:原有
removefileFile方法仅支持删除编辑态下关联的单个已持久化文件,无法删除本次选中未提交的待上传文件
修正后实现代码
TypeScript 组件代码
export class CreateOrEditTooolModalComponent extends AppComponentBase implements OnInit { @ViewChild('createOrEditModal', { static: true }) modal: ModalDirective; @Output() modalSave: EventEmitter<any> = new EventEmitter<any>(); active = false; saving = false; toool: CreateOrEditTooolDto = new CreateOrEditTooolDto(); fileFileUploader: FileUploader; fileFileAcceptedTypes: string = ''; // 多文件列表存储:区分已存旧文件/新选文件,记录id、名称、上传token selectedFiles: Array<{ id?: string; name: string; fileToken?: string; isNew: boolean; }> = []; @ViewChild('Toool_fileLabel') toool_fileLabel: ElementRef; @ViewChild('Toool_fileInput') toool_fileInput: ElementRef; constructor( injector: Injector, private _tooolsServiceProxy: TooolsServiceProxy, private _dateTimeService: DateTimeService, private _tokenService: TokenService, private _http: HttpClient ) { super(injector); } show(tooolId?: number): void { // 每次打开弹窗重置状态 this.selectedFiles = []; this.updateFileLabelText(); if (!tooolId) { this.toool = new CreateOrEditTooolDto(); this.toool.id = tooolId; this.active = true; this.modal.show(); } else { this._tooolsServiceProxy.getTooolForEdit(tooolId).subscribe((result) => { this.toool = result.toool; // 编辑态加载已存文件到列表 if (result.existFiles?.length) { this.selectedFiles = result.existFiles.map(f => ({ id: f.id, name: f.fileName, fileToken: f.fileToken, isNew: false })); } this.updateFileLabelText(); this.active = true; this.modal.show(); }); } this.fileFileUploader = this.initializeUploader( AppConsts.remoteServiceBaseUrl + '/Toools/UploadfileFile', ); } save(): void { this.saving = true; // 收集所有有效文件token传给后端 this.toool.fileTokens = this.selectedFiles.map(f => f.fileToken).filter(Boolean); this._tooolsServiceProxy .createOrEdit(this.toool) .pipe( finalize(() => { this.saving = false; }) ) .subscribe(() => { this.notify.info(this.l('SavedSuccessfully')); this.close(); this.modalSave.emit(null); }); } onSelectfileFile(fileInput: Event): void { const target = fileInput.target as HTMLInputElement; const files = target.files; if (!files || files.length === 0) { this.updateFileLabelText(); return; } // 遍历所有选中文件加入上传队列,不清除历史已选 for (let i = 0; i < files.length; i++) { const file = files[i]; // 同文件名去重,避免重复添加 const existIndex = this.selectedFiles.findIndex(f => f.name === file.name && f.isNew); if (existIndex === -1) { this.fileFileUploader.addToQueue([file]); this.selectedFiles.push({ name: file.name, isNew: true }); } } // 清空input值,支持重复选择相同文件 target.value = ''; this.updateFileLabelText(); this.fileFileUploader.uploadAll(); } // 删除单条选中文件 removeSelectedFile(index: number): void { const targetFile = this.selectedFiles[index]; this.message.confirm(this.l('DoYouWantToRemoveTheFile'), this.l('AreYouSure'), (isConfirmed) => { if (!isConfirmed) return; // 已持久化的旧文件,调用后端接口删除 if (!targetFile.isNew && targetFile.id) { this._tooolsServiceProxy.removeSingleFile(targetFile.id).subscribe(() => { abp.notify.success(this.l('SuccessfullyDeleted')); this.selectedFiles.splice(index, 1); this.updateFileLabelText(); }); return; } // 新选的待提交文件,直接从上传队列和本地列表移除 const queueItem = this.fileFileUploader.queue.find(item => item.file.name === targetFile.name); if (queueItem) queueItem.remove(); this.selectedFiles.splice(index, 1); this.updateFileLabelText(); }); } initializeUploader(url: string): FileUploader { let uploader = new FileUploader({ url: url }); let _uploaderOptions: FileUploaderOptions = {}; _uploaderOptions.autoUpload = false; _uploaderOptions.authToken = 'Bearer ' + this._tokenService.getToken(); _uploaderOptions.removeAfterUpload = true; uploader.onAfterAddingFile = (file) => { file.withCredentials = false; }; uploader.onSuccessItem = (item, response, status) => { const resp = <IAjaxResponse>JSON.parse(response); if (resp.success && resp.result.fileToken) { // 匹配对应文件,存储服务端返回的token const target = this.selectedFiles.find(f => f.name === item.file.name && f.isNew && !f.fileToken); if (target) target.fileToken = resp.result.fileToken; } else { this.message.error(resp.result?.message || '文件上传失败'); // 上传失败移除对应条目 this.selectedFiles = this.selectedFiles.filter(f => f.name !== item.file.name); this.updateFileLabelText(); } }; uploader.setOptions(_uploaderOptions); return uploader; } // 更新文件选择框提示文字 updateFileLabelText(): void { if (!this.selectedFiles.length) { this.toool_fileLabel.nativeElement.innerText = this.l('ChooseAFile'); return; } this.toool_fileLabel.nativeElement.innerText = `已选择${this.selectedFiles.length}个文件`; } getDownloadUrl(id: string): string { return AppConsts.remoteServiceBaseUrl + '/File/DownloadBinaryFile?id=' + id; } close(): void { this.active = false; this.modal.hide(); // 关闭时清空上传队列 this.fileFileUploader?.clearQueue(); } ngOnInit(): void { this._http.get(AppConsts.remoteServiceBaseUrl + '/toools/GetfileFileAllowedTypes').subscribe((data: any) => { if (!data || !data.result) return; let list = data.result as string[]; if (list.length == 0) return; this.fileFileAcceptedTypes = list.map(ext => `.${ext}`).join(','); }); } }
HTML模板代码
<div class="my-3"> <div class="input-group"> <span class="input-group-text" id="inputGroupFileAddon01">{{ l('file') }}</span> <div class="form-control"> <input type="file" multiple class="custom-file-input" id="Toool_file" name="Toool_file" #Toool_fileInput aria-describedby="inputGroupFileAddon01" (change)="onSelectfileFile($event)" [accept]="fileFileAcceptedTypes" /> <label class="custom-file-label" #Toool_fileLabel for="Toool_file"> {{ l('ChooseAFile') }} </label> </div> </div> <div *ngIf="selectedFiles.length > 0" style="background-color: #e5e5e5; padding: 5px"> <span class="font-weight-bold">{{ 'SelectedFiles' | localize }} :</span> <div class="alert alert-custom alert-white alert-bold m-1" role="alert" *ngFor="let file of selectedFiles; let i = index"> <div class="alert-text"> <ng-container *ngIf="!file.isNew"> <a href="{{ getDownloadUrl(file.id) }}" target="_blank">{{ file.name }}</a> </ng-container> <ng-container *ngIf="file.isNew"> {{ file.name }} <span class="text-success ml-2" *ngIf="file.fileToken">✓ 已上传</span> <span class="text-warning ml-2" *ngIf="!file.fileToken">上传中...</span> </ng-container> </div> <div class="alert-close"> <button type="button" class="close" (click)="removeSelectedFile(i)" aria-label="Close"> <span aria-hidden="true"> <i class="la la-close fa-1x"></i> </span> </button> </div> </div> </div> </div>
配套调整说明
- 后端接口需要同步适配:原单文件上传接口保持单文件上传逻辑即可(多文件会逐个触发上传请求),创建/编辑接口将原单
fileToken入参改为fileTokens数组接收,新增单文件删除接口支持删除已绑定到业务实体的文件 - 可根据业务需求在
onAfterAddingFile回调中增加文件大小、类型校验,不符合规则的文件直接移出队列并弹出提示 - 编辑态接口返回需要增加已关联文件列表字段,返回每个文件的id、名称、token用于前端渲染
内容的提问来源于stack exchange,提问作者ravindra
相关产品推荐
相关产品推荐

