el-uploadを使ったファイル添付機能

#概要
laravel+vueで作成したWebサイトにて添付ファイル機能を実装したのでメモ。
フロント側のUIはelement-uiのel-uploadで作った。

#コード

###vue側

```vue

<el-row :gutter="24" class="attachment-row">
  <el-col :span="24">
    <span class="item-label"><i class="fas fa-paperclip icon"></i>添付ファイル</span>
    <el-upload
      action="#"
      list-type="text"
      class="attachments-upload"
      :before-remove="beforeAttachmentsRemove"
      :on-change="addAttachments"
      :file-list="attachmentsList"
      :auto-upload="true"
      :show-file-list="true"
      :multiple="false"
      :drag="true"
      :limit="attachments_limit"
      :http-request="AttachmentsHttpRequest"
    >
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">
        ここにドラッグするか <em>ここをクリックして</em>アップロードしてください。
      </div>
      <div slot="file" slot-scope="{ file }">
        <div class="contents" :class="{ delete: file.isDelete }">
          <span v-if="file.isDelete" class="el-upload-list__item-deleted">
            <span class="el-upload-list__item-label">
              <span class="each-row">削除は更新実行後に反映されます。</span>
            </span>
          </span>
          <li>
            <div v-if="!file.isUploaded">
              <i class="el-icon-document icon"></i>
              <span v-if="!file.isUploaded" class="el-upload-list__item-noactions">
                <span class="el-upload-list__item-label">
                  <span class="each-row">アップロード</span>
                  <span class="each-row">待ち</span>
                </span>
              </span>
              <span class="filename">{{ file.name }}</span>
              <i
                class="el-icon-close"
                :disabled="file.isDelete"
                @click="deleteAttachments(file)"
              ></i>
            </div>
            <div v-else class="available-download">
              <i class="el-icon-document icon" @click="downloadAttachment(file)"></i>
              <span class="filename" @click="downloadAttachment(file)">{{
                file.name
              }}</span>
              <i
                class="el-icon-close close-icon"
                :disabled="file.isDelete"
                @click="deleteAttachments(file)"
              ></i>
            </div>
          </li>
        </div>
      </div>
    </el-upload>
  </el-col>
</el-row>

(略)
private attachments_limit: number = 10;
private attachmentsList: AttachementData[] = [];
private fileReader: FileReader = new FileReader();

//添付ファイル追加(AttachmentsHttpRequestより後で呼ばれる)
addAttachments(file: any, fileList: any) {
  // File size limitation
  const isLt5M = file.size / 1024 / 1024 < 100;
  if (!isLt5M) {
    this.$message.error("アップロードできるファイルサイズは100Mまでです。");
    this.deleteAttachments(file);
    return false;
  }
  // Exceed limit
  if (this.attachmentsList.length >= this.attachments_limit) {
    this.$message.error("アップロードできるファイルは" + this.attachments_limit + "つまでです。");
    this.deleteAttachments(file);
    return false;
  }

  file.id = file.uid;
  file.isUploaded = false;
  file.isDelete = false;
  this.attachmentsList.push(file);
}

//添付ファイル削除
deleteAttachments(file: any) {
  if (file.isUploaded) {
    //アップロード済み

    //画面表示リストの削除フラグ立てる
    let t = this.attachmentsList.filter(function(e) {
      return e === file;
    });
    t[0].isDelete = true;

    //サーバへ送るリストの削除フラグを立てる
    let t2 = this.minute.attachments_base64.filter(function(e) {
      return e.id == file.uid;
    });
    t2[0].isDelete = true;
  } else {
    //未アップロード

    //画面表示リストから消す
    this.attachmentsList = this.attachmentsList.filter(function(e) {
      return e !== file;
    });

    //サーバへ送るリストから消す
    this.minute.attachments_base64 = this.minute.attachments_base64.filter(function(e) {
      return e.id != file.uid;
    });
  }
}

//添付ファイル追加時のアップロード
AttachmentsHttpRequest(options: any) {
  let file = options.file;
  let filename = file.name;
  if (file) {
    this.fileReader.readAsDataURL(file);
  }
  this.fileReader.onload = () => {
    if (!this.minute.attachments_base64.length) {
      this.minute.attachments_base64 = [];
    }
    this.minute.attachments_base64.push({
      id: file.uid,
      filetype: file.type,
      original_filename: filename,
      comment: "", //アップロード時は空
      base64data: this.fileReader.result,
      isUploaded: false,
      isDelete: false
    });
  };
}

//画面初期表示時に既存添付ファイルを反映する
defaultAttachmentsListLoad(data: Attachement[]) {
  this.attachmentsList = [];
  data.forEach((value, index) => {
    if (this.attachmentsList.length < this.attachments_limit) {
      this.attachmentsList.push({
        id: value.id,
        name: value.original_filename,
        comment: value.comment,
        isUploaded: true,
        isDelete: false,
        uid: value.id,
        url: ""
      });
    }
  });
}

//添付ファイルの属性入力時にdeleteキーやBackSpaceキーでリスト削除が走らないようにするための処理
beforeAttachmentsRemove(file, fileList) {
  return false;
}

/**
 * 添付ファイルダウンロード
 */
downloadAttachment(file: any) {
  const _this = this;
  axios({
    url: "/api/minute/download/attachment",
    params: { minute_id: this.minute.id, attachment_id: file.uid },
    method: "GET",
    responseType: "blob" // これがないと文字化けする
  })
    .then(res => {
      const blob = new Blob([res.data], {
        type: res.data.type
      });

      //レスポンスヘッダからファイル名を取得します
      var fileName = res.headers["download_filename"];
      fileName = decodeURI(fileName).replace(/\+/g, " ");

      //ダウンロードします
      saveAs(blob, fileName);
    })
    .catch(error => {
      var errorMsg = "不明";

      // 通知
      _this.$message({
        message: "実行に失敗しました。[" + errorMsg + "]",
        type: "error",
        duration: 0,
        showClose: true
      });
    });
}
```

###バックエンド側
```php:[画面表示時のメタデータ取得]
//添付ファイル(メタ情報だけ返す)
$attachments_base64 = [];
if ($minute->attachments) {
  $attachment_paths = json_decode($minute->attachments);
  foreach ($attachment_paths as $file) {
    $attachments_base64[] = [
      'id' => $file->id,
      'original_filename' => $file->original_filename,
      'isUploaded' => true,
      'isDelete' => false
    ];
  }
}
$minute['attachments_base64'] = $attachments_base64;
```

```php:[保存時]
//添付ファイルをファイルとして保存する
$attachment_json = [];
foreach ($request->attachments_base64 as $file) {
  $path = str_pad($minute->id, 10, '0', 0) . '_' . $file['id'];
  if ($file['isUploaded'] && !$file['isDelete']) {
    //アップロード済みで削除でない->何もしない
    $attachment_json[] = ['id' => $file['id'], 'path' => $path, 'original_filename' => $file['original_filename']];
  } elseif ($file['isUploaded'] && $file['isDelete']) {
    //アップロード済みで削除する->実体ファイルを削除しJSONに書き込まない
    if (Storage::disk('local')->exists('/minute/' . $path)) {
      Storage::disk('local')->delete('/minute/' . $path);
    }
  } elseif (!$file['isUploaded']) {
    //アップロード済されていない->アップロードする
    $fileData = $file['base64data'];
    if (count(explode(';', $fileData)) > 1) {
      list(, $fileData) = explode(';', $fileData);
    }
    if (count(explode(',', $fileData)) > 1) {
      list(, $fileData) = explode(',', $fileData);
    }
    $fileData = base64_decode($fileData);
    Storage::disk('local')->put('/minute/' . $path, $fileData);
    $attachment_json[] = ['id' => $file['id'], 'path' => $path, 'original_filename' => $file['original_filename']];
  }
}
$minute->attachments = json_encode($attachment_json, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$minute->save(); //DBに添付ファイルパスを更新
```

```php:[ダウンロード時]
public function attachmentDownload(Request $request)
{
  $minute = Minutes:: firstOrNew(['id' => $request->minute_id]);

  //添付ファイル
  if ($minute->attachments) {
    $attachment_paths = json_decode($minute->attachments);
    foreach ($attachment_paths as $file) {
      if ($file->id == $request->attachment_id) {
        $mimeType = Storage::mimeType('/minute/' . $file->path);
        $headers = [['Content-Type' => $mimeType], 'download_filename' => urlencode($file->original_filename)];
        return Storage::response('/minute/' . $file->path, $file->original_filename, $headers, 'attachment');
      }
    }
  }
}
```