Kaynağa Gözat

feat: implement chunked file upload functionality

Shinohara Haruna 6 ay önce
ebeveyn
işleme
f9d3968a5b

+ 14 - 0
smsb-plus-ui/src/api/smsb/source/minioData.ts

@@ -95,3 +95,17 @@ export const diskUse = () => {
     method: 'get'
   });
 };
+
+/**
+ * 上传分片(适配 SmsbFileUploader.vue 组件分片上传请求)
+ * @param data FormData
+ */
+export const uploadChunk = (data: FormData) => {
+  return request({
+    url: '/source/minioData/multipart/chunk',
+    method: 'post',
+    headers: { 'Content-Type': 'multipart/form-data' },
+    data
+  });
+};
+

+ 211 - 23
smsb-plus-ui/src/components/SmsbFileUpload/SmsbFileUploader.vue

@@ -37,23 +37,23 @@
           <el-table-column label="状态/进度" width="180">
             <template #default="{ row }">
               <div v-if="uploadStates[row.uid]">
-                <el-progress v-if="uploadStates[ossId]?.status === 'uploading'"
-                             :percentage="uploadStates[ossId]?.progress || 0" :stroke-width="8" striped striped-flow
+                <el-progress v-if="uploadStates[row.uid]?.status === 'uploading'"
+                             :percentage="uploadStates[row.uid]?.progress || 0" :stroke-width="8" striped striped-flow
                              :duration="10" />
-                <el-tag v-else-if="uploadStates[ossId]?.status === 'success'" type="success">上传成功</el-tag>
-                <span v-else-if="uploadStates[ossId]?.status === 'error'">
+                <el-tag v-else-if="uploadStates[row.uid]?.status === 'success'" type="success">上传成功</el-tag>
+                <span v-else-if="uploadStates[row.uid]?.status === 'error'">
                   <el-tag type="danger">上传失败</el-tag>
-                  <el-tooltip v-if="uploadStates[ossId]?.errorMessage"
-                              :content="uploadStates[ossId]?.errorMessage || '未知错误'" placement="top">
+                  <el-tooltip v-if="uploadStates[row.uid]?.errorMessage"
+                              :content="uploadStates[row.uid]?.errorMessage || '未知错误'" placement="top">
                     <el-icon style="margin-left: 4px; vertical-align: middle; cursor: help">
                       <QuestionFilled />
                     </el-icon>
                   </el-tooltip>
                 </span>
-                <el-tag v-else-if="uploadStates[ossId]?.status === 'merging'" type="warning"
+                <el-tag v-else-if="uploadStates[row.uid]?.status === 'merging'" type="warning"
                         effect="light">合并中...</el-tag>
-                <el-tag v-else-if="uploadStates[ossId]?.status === 'pending'" type="info">待上传</el-tag>
-                <el-tag v-else type="info" effect="plain">未知状态 ({{ uploadStates[ossId]?.status }})</el-tag>
+                <el-tag v-else-if="uploadStates[row.uid]?.status === 'pending'" type="info">待上传</el-tag>
+                <el-tag v-else type="info" effect="plain">未知状态 ({{ uploadStates[row.uid]?.status }})</el-tag>
               </div>
               <el-tag v-else type="info">待上传</el-tag>
             </template>
@@ -61,7 +61,7 @@
           <el-table-column fixed="right" label="操作" width="120">
             <template #default="{ row }">
               <el-button type="danger" link @click="handleManualRemove(row)"
-                         :disabled="uploadStates[ossId]?.status === 'uploading'">删除</el-button>
+                         :disabled="uploadStates[row.uid]?.status === 'uploading'">删除</el-button>
             </template>
           </el-table-column>
         </el-table>
@@ -111,12 +111,19 @@ interface UploadState {
   status: string;
   progress?: number;
   errorMessage?: string;
+  uploadId?: string;
+  totalChunks?: number;
+  uploadedChunks?: Set<number>;
+  finalUrl?: string | null;
+  historySaved?: boolean;
 }
 interface UploadHistoryEntry {
   name: string;
   size: number;
   status: string;
   uploadTime: string;
+  url?: string | null;
+  uploadId?: string;
 }
 
 // ================= props, emits, 变量声明 =================
@@ -149,6 +156,12 @@ const uploadStates = reactive<Record<string | number, UploadState>>({});
 const uploadHistory = ref<UploadHistoryEntry[]>([]);
 const MAX_HISTORY_ITEMS = 10;
 
+// 分片上传相关常量
+// const API_ENDPOINT = "http://localhost:8087/api/upload"; // TODO: 替换为实际后端API
+const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB
+import { ElMessage, ElMessageBox } from "element-plus";
+import { uploadChunk } from '@/api/smsb/source/minioData';
+
 const isUploading = computed(() => {
   return Object.values(uploadStates).some((state) => state.status === 'uploading');
 });
@@ -183,12 +196,14 @@ function saveHistory() {
   localStorage.setItem('smsb-upload-history', JSON.stringify(uploadHistory.value.slice(0, MAX_HISTORY_ITEMS)));
 }
 // TODO: 可扩展:支持多文件上传/批量操作时的历史记录批量追加
-function addHistoryEntry(file: any, status: string) {
+function addHistoryEntry(file: any, status: string, url?: string | null, uploadId?: string) {
   uploadHistory.value.unshift({
     name: file.name,
     size: file.size,
     status,
-    uploadTime: new Date().toLocaleString()
+    uploadTime: new Date().toLocaleString(),
+    url: url || null,
+    uploadId: uploadId || undefined
   });
   uploadHistory.value = uploadHistory.value.slice(0, MAX_HISTORY_ITEMS);
   saveHistory();
@@ -236,19 +251,192 @@ function handleManualRemove(fileToRemove: any) {
   delete uploadStates[fileToRemove.uid];
 }
 
-// TODO: 对接实际上传API,完善错误处理与用户提示,支持多文件上传/批量操作
+// ========== 分片上传核心逻辑 ==========
+async function sendChunkRequest(formData: FormData, fileUid: string | number, chunkIndex: number, file: any): Promise<void> {
+  try {
+    const response = await uploadChunk(formData);
+    if (response.status === 200 && response.data?.success) {
+      const state = uploadStates[fileUid];
+      if (!state) return;
+      state.uploadedChunks?.add(chunkIndex);
+      state.progress = Math.round((state.uploadedChunks!.size / (state.totalChunks || 1)) * 100);
+      const isLastChunk = chunkIndex === (state.totalChunks || 1) - 1;
+      const fileUrl = response.data.data?.fileUrl;
+      if (isLastChunk && fileUrl) {
+        if (state.status !== "success" && !state.historySaved) {
+          state.finalUrl = fileUrl;
+          state.status = "success";
+          state.progress = 100;
+          addHistoryEntry(file, "success", state.finalUrl, state.uploadId);
+          state.historySaved = true;
+        }
+      } else if (isLastChunk && !fileUrl) {
+        if (
+          state.uploadedChunks!.size === state.totalChunks &&
+          state.status === "uploading"
+        ) {
+          state.status = "merging";
+          state.progress = 100;
+          setTimeout(() => {
+            const currentState = uploadStates[fileUid];
+            if (currentState?.status === "merging" && !currentState.historySaved) {
+              currentState.status = "success";
+              addHistoryEntry(file, "success", currentState.finalUrl, currentState.uploadId);
+              currentState.historySaved = true;
+              ElMessage.success(`${file.name} 上传并合并成功!`);
+            }
+          }, 5000);
+        }
+      }
+    } else {
+      throw new Error(response.data?.message || `Chunk ${chunkIndex + 1} upload failed with status ${response.status}`);
+    }
+  } catch (error: any) {
+    const state = uploadStates[fileUid];
+    if (state && state.status !== "error") {
+      state.status = "error";
+      state.errorMessage = error.response?.data?.message || error.message || "上传分片时发生网络或服务器错误";
+    }
+    throw error;
+  }
+}
+
+async function uploadFileChunks(file: any) {
+  const rawFile = file.raw;
+  if (!rawFile) {
+    uploadStates[file.uid].status = "error";
+    uploadStates[file.uid].errorMessage = "文件数据无效";
+    return;
+  }
+  const fileUid = file.uid;
+  const totalChunks = Math.ceil(rawFile.size / CHUNK_SIZE);
+  // 使用 ossId 作为 uploadId
+  const uploadId = ossId.value || fileUid;
+  uploadStates[fileUid] = {
+    ...uploadStates[fileUid],
+    status: "uploading",
+    uploadId,
+    totalChunks,
+    progress: 0,
+    errorMessage: null,
+    uploadedChunks: new Set(),
+    finalUrl: null,
+    historySaved: false,
+  };
+  const chunkPromises: Promise<void>[] = [];
+  for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+    const start = chunkIndex * CHUNK_SIZE;
+    const end = Math.min(start + CHUNK_SIZE, rawFile.size);
+    const chunk = rawFile.slice(start, end);
+    const formData = new FormData();
+    formData.append("file", chunk, `${file.name}.chunk${chunkIndex}`);
+    formData.append("filename", file.name);
+    formData.append("chunkIndex", String(chunkIndex));
+    formData.append("totalChunks", String(totalChunks));
+    formData.append("fileSize", String(rawFile.size));
+    formData.append("fileType", rawFile.type || "application/octet-stream");
+    formData.append("uploadId", uploadId);
+    // 打印每个分片的参数
+    console.log('[分片上传] chunk params', {
+      uploadId,
+      filename: file.name,
+      chunkIndex,
+      totalChunks,
+      fileSize: rawFile.size,
+      fileType: rawFile.type || "application/octet-stream"
+    });
+    chunkPromises.push(sendChunkRequest(formData, fileUid, chunkIndex, file));
+  }
+  try {
+    const results = await Promise.allSettled(chunkPromises);
+    const failedChunk = results.find((result) => result.status === "rejected");
+    if (failedChunk) {
+      uploadStates[fileUid].status = "error";
+      uploadStates[fileUid].errorMessage = uploadStates[fileUid].errorMessage || "一个或多个分片上传失败";
+      ElMessage.error(`${file.name} 上传失败: ${uploadStates[fileUid]?.errorMessage}`);
+    } else {
+      const finalState = uploadStates[fileUid];
+      if (finalState && finalState.status === "uploading") {
+        if (finalState.uploadedChunks!.size === totalChunks) {
+          if (!finalState.historySaved) {
+            finalState.status = "success";
+            finalState.progress = 100;
+            addHistoryEntry(file, "success", finalState.finalUrl, finalState.uploadId);
+            finalState.historySaved = true;
+          }
+        } else {
+          finalState.status = "error";
+          finalState.errorMessage = "内部状态不一致";
+        }
+      } else if (finalState && (finalState.status === "success" || finalState.status === "merging")) {
+        // 已完成
+      }
+    }
+  } catch (error) {
+    uploadStates[fileUid].status = "error";
+    uploadStates[fileUid].errorMessage = "上传过程中发生意外错误";
+    ElMessage.error(`${file.name} 上传失败: ${uploadStates[fileUid]?.errorMessage || "未知错误"}`);
+  }
+}
+
+// 多文件上传入口,弹窗确认
 async function handleUpload() {
-  for (const file of fileList.value) {
-    uploadStates[file.uid] = { status: 'uploading', progress: 0 };
-    try {
-      // 这里只是模拟上传,实际应替换为后端API
-      await new Promise((resolve) => setTimeout(resolve, 1500));
-      uploadStates[file.uid] = { status: 'success', progress: 100 };
-      addHistoryEntry(file, 'success');
-    } catch (e: any) {
-      uploadStates[file.uid] = { status: 'error', errorMessage: e?.message || '上传失败' };
-      addHistoryEntry(file, 'error');
+  const filesToUpload = fileList.value.filter(
+    (file) =>
+      file.uid !== undefined &&
+      (!uploadStates[file.uid] ||
+        uploadStates[file.uid]?.status === "pending" ||
+        uploadStates[file.uid]?.status === "error")
+  );
+  if (filesToUpload.length === 0) {
+    ElMessage.info("没有待上传或上传失败的文件。");
+    return;
+  }
+  try {
+    await ElMessageBox.confirm(
+      `即将上传 ${filesToUpload.length} 个文件。确定吗?`,
+      "开始上传",
+      { confirmButtonText: "确定", cancelButtonText: "取消", type: "info" }
+    );
+    filesToUpload.forEach((file) => {
+      if (
+        file.uid !== undefined &&
+        uploadStates[file.uid]?.status === "error"
+      ) {
+        const existingState = uploadStates[file.uid];
+        uploadStates[file.uid] = {
+          status: "pending",
+          progress: 0,
+          uploadId: existingState?.uploadId || null,
+          totalChunks: 0,
+          errorMessage: null,
+          uploadedChunks: new Set(),
+          finalUrl: null,
+          historySaved: false,
+        };
+      }
+    });
+    const allUploadPromises = filesToUpload.map((file) => uploadFileChunks(file));
+    await Promise.allSettled(allUploadPromises);
+    const successfulUploads = fileList.value.filter(
+      (f) => f.uid !== undefined && uploadStates[f.uid]?.status === "success"
+    ).length;
+    const failedUploads = fileList.value.filter(
+      (f) => f.uid !== undefined && uploadStates[f.uid]?.status === "error"
+    ).length;
+    const stillProcessing = fileList.value.filter(
+      (f) =>
+        f.uid !== undefined &&
+        (uploadStates[f.uid]?.status === "uploading" ||
+          uploadStates[f.uid]?.status === "merging")
+    ).length;
+    let summaryMessage = `上传任务处理完成: ${successfulUploads} 个成功, ${failedUploads} 个失败。`;
+    if (stillProcessing > 0) {
+      summaryMessage += ` ${stillProcessing} 个仍在处理中。`;
     }
+    ElMessage.info(summaryMessage);
+  } catch {
+    ElMessage.info("上传已取消");
   }
 }
 </script>

+ 3 - 1
smsb-plus-ui/src/utils/request.ts

@@ -54,7 +54,9 @@ service.interceptors.request.use(
       config.url = url;
     }
 
-    if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
+    // 文件上传接口不校验重复提交
+    const isFileUpload = config.url?.includes('/source/minioData/multipart/chunk');
+    if (!isFileUpload && !isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
       const requestObj = {
         url: config.url,
         data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,