You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 12:48:18