Browse Source

feat(SmsbFileUpload): Add new file uploader component with drag-and-drop and history tracking

Shinohara Haruna 6 months ago
parent
commit
2fcfe31fa6

+ 315 - 0
smsb-plus-ui/src/components/SmsbFileUpload/SmsbFileUploader.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="file-uploader">
+    <!-- 文件拖拽区域 -->
+    <el-upload class="upload-area" drag action="#" :auto-upload="false" :on-change="handleFileChange"
+               :on-remove="handleFileRemove" :file-list="displayFileList" multiple>
+      <el-icon class="upload-icon"><upload-filled /></el-icon>
+      <div class="upload-text">
+        <em>拖拽文件到此处或</em>
+        <br />
+        <el-button type="primary" class="select-button">
+          点击选择文件
+        </el-button>
+      </div>
+    </el-upload>
+
+    <!-- 文件类型选择 (仅测试,不直接用于上传API,后续可改) -->
+    <div class="type-selection">
+      <el-select v-model="selectedType" placeholder="请选择文件类型" class="type-select">
+        <el-option v-for="item in fileTypes" :key="item.value" :label="item.label" :value="item.value" />
+      </el-select>
+      <el-tooltip content="此类型选择仅用于应用逻辑,上传时将使用文件的实际MIME类型。" placement="top">
+        <el-icon style="margin-left: 8px; vertical-align: middle">
+          <QuestionFilled />
+        </el-icon>
+      </el-tooltip>
+    </div>
+
+    <!-- 已选文件列表 -->
+    <div v-if="fileList.length > 0" class="file-list">
+      <h3>待上传文件:</h3>
+      <el-scrollbar height="200px">
+        <el-table :data="fileList" style="width: 100%">
+          <el-table-column prop="name" label="文件名" />
+          <el-table-column prop="size" label="大小" width="120">
+            <template #default="{ row }">
+              {{ formatFileSize(row.size || 0) }}
+            </template>
+          </el-table-column>
+          <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
+                             :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 type="danger">上传失败</el-tag>
+                  <el-tooltip v-if="uploadStates[ossId]?.errorMessage"
+                              :content="uploadStates[ossId]?.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" 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>
+              </div>
+              <el-tag v-else type="info">待上传</el-tag>
+            </template>
+          </el-table-column>
+          <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>
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-scrollbar>
+    </div>
+
+    <!-- 上传按钮和历史记录 -->
+    <div class="upload-actions">
+      <el-button type="primary" :disabled="isUploading || fileList.length === 0" @click="handleUpload">上传</el-button>
+      <el-button @click="clearHistory" type="info" plain v-if="uploadHistory.length > 0">清空历史</el-button>
+    </div>
+    <div v-if="uploadHistory.length > 0" class="history-list">
+      <h3>上传历史记录 (最近 {{ MAX_HISTORY_ITEMS }} 条):</h3>
+      <el-scrollbar height="250px">
+        <el-table :data="uploadHistory" style="width: 100%">
+          <el-table-column prop="name" label="文件名" />
+          <el-table-column prop="size" label="大小" width="120">
+            <template #default="{ row }">
+              {{ formatFileSize(row.size || 0) }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="status" label="状态" width="100">
+            <template #default="{ row }">
+              <el-tag v-if="row.status === 'success'" type="success">成功</el-tag>
+              <el-tag v-else-if="row.status === 'error'" type="danger">失败</el-tag>
+              <el-tag v-else type="info">{{ row.status }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="uploadTime" label="上传时间" width="180" />
+          <el-table-column label="操作" width="80">
+            <template #default="{ $index }">
+              <el-button type="danger" link @click="removeHistoryItem($index)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-scrollbar>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, reactive, onMounted, watch, defineProps, defineEmits } from "vue";
+import { UploadFilled, QuestionFilled } from "@element-plus/icons-vue";
+
+// ================= 类型声明 =================
+interface UploadState {
+  status: string;
+  progress?: number;
+  errorMessage?: string;
+}
+interface UploadHistoryEntry {
+  name: string;
+  size: number;
+  status: string;
+  uploadTime: string;
+}
+
+// ================= props, emits, 变量声明 =================
+const props = defineProps({
+  modelValue: {
+    type: [String, Number, null],
+    default: null
+  }
+}); // only declared once
+const emit = defineEmits(["update:modelValue"]); // only declared once
+
+const fileTypes = ref([
+  { label: "图片", value: "image" },
+  { label: "视频", value: "video" },
+  { label: "文档", value: "document" },
+  { label: "其它", value: "other" },
+]);
+const selectedType = ref("");
+
+const fileList = ref<any[]>([]);
+const displayFileList = computed(() => fileList.value);
+
+// ossId 用于文件唯一标识
+const ossId = computed({
+  get: () => props.modelValue,
+  set: (val) => emit("update:modelValue", val)
+});
+
+const uploadStates = reactive<Record<string | number, UploadState>>({});
+const uploadHistory = ref<UploadHistoryEntry[]>([]);
+const MAX_HISTORY_ITEMS = 10;
+
+const isUploading = computed(() => {
+  return Object.values(uploadStates).some(
+    (state) => state.status === "uploading"
+  );
+});
+
+// ================= 监听器 =================
+watch(
+  () => ossId.value,
+  (newId) => {
+    // 可扩展:根据新的 ossId 加载状态/历史等
+  }
+);
+
+
+function formatFileSize(bytes: number): string {
+  if (bytes < 1024) return bytes + " B";
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+  if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + " MB";
+  return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB";
+}
+
+// TODO: 上传历史的持久化与回显逻辑可根据业务扩展
+function loadHistory() {
+  const raw = localStorage.getItem("smsb-upload-history");
+  if (raw) {
+    try {
+      uploadHistory.value = JSON.parse(raw);
+    } catch {
+      uploadHistory.value = [];
+    }
+  }
+}
+function saveHistory() {
+  localStorage.setItem("smsb-upload-history", JSON.stringify(uploadHistory.value.slice(0, MAX_HISTORY_ITEMS)));
+}
+// TODO: 可扩展:支持多文件上传/批量操作时的历史记录批量追加
+function addHistoryEntry(file: any, status: string) {
+  uploadHistory.value.unshift({
+    name: file.name,
+    size: file.size,
+    status,
+    uploadTime: new Date().toLocaleString(),
+  });
+  uploadHistory.value = uploadHistory.value.slice(0, MAX_HISTORY_ITEMS);
+  saveHistory();
+}
+function clearHistory() {
+  uploadHistory.value = [];
+  saveHistory();
+}
+function removeHistoryItem(index: number) {
+  uploadHistory.value.splice(index, 1);
+  saveHistory();
+}
+
+onMounted(() => {
+  loadHistory();
+});
+
+// 上传成功逻辑示例(请根据实际上传API修改)
+// TODO: 对接实际上传API,处理后端返回、进度、错误、合并等流程
+function handleUploadSuccess(response: any) {
+  // 假设 response.data.ossId 为后端返回的文件唯一ID
+  if (response && response.data && response.data.ossId) {
+    ossId.value = response.data.ossId;
+    uploadStates[response.data.ossId] = { status: 'success', progress: 100 };
+    // 追加到历史
+    addHistoryEntry({ ...fileList.value[0], ossId: response.data.ossId }, 'success');
+  }
+}
+
+function handleFileChange(uploadFile: any, uploadFiles: any[]) {
+  // TODO: 文件类型、大小等前端校验
+
+  // 避免重复添加
+  if (!fileList.value.some((f) => f.uid === uploadFile.uid)) {
+    fileList.value.push(uploadFile);
+    uploadStates[uploadFile.uid] = { status: "pending" };
+  }
+}
+function handleFileRemove(uploadFile: any, uploadFiles: any[]) {
+  fileList.value = fileList.value.filter((f) => f.uid !== uploadFile.uid);
+  delete uploadStates[uploadFile.uid];
+}
+function handleManualRemove(fileToRemove: any) {
+  fileList.value = fileList.value.filter((f) => f.uid !== fileToRemove.uid);
+  delete uploadStates[fileToRemove.uid];
+}
+
+// TODO: 对接实际上传API,完善错误处理与用户提示,支持多文件上传/批量操作
+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");
+    }
+  }
+}
+</script>
+
+<style scoped>
+.file-uploader {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  box-sizing: border-box;
+  padding: 0 0 16px 0;
+}
+.upload-area, .file-list, .upload-actions, .history-list {
+  width: 100%;
+}
+
+.upload-area {
+  border: 2px dashed #d9d9d9;
+  border-radius: 8px;
+  padding: 24px 0;
+  margin-bottom: 16px;
+  background: #fafafa;
+  text-align: center;
+  width: 100%;
+}
+.upload-icon {
+  font-size: 40px;
+  color: #409eff;
+  margin-bottom: 10px;
+}
+.upload-text {
+  font-size: 16px;
+  color: #606266;
+}
+.select-button {
+  margin-top: 10px;
+}
+.type-selection {
+  margin-bottom: 12px;
+  display: flex;
+  align-items: center;
+}
+.type-select {
+  width: 180px;
+}
+.file-list {
+  margin-bottom: 16px;
+  width: 100%;
+}
+.upload-actions {
+  margin: 18px 0 12px 0;
+  display: flex;
+  gap: 12px;
+}
+.history-list {
+  margin-top: 18px;
+}
+</style>

+ 17 - 6
smsb-plus-ui/src/views/smsb/minioData/index.vue

@@ -157,9 +157,9 @@
       <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
     </el-card>
     <!-- 添加或修改文件资源对话框 -->
-    <el-dialog :title="dialog.title" v-model="dialog.visible" width="850px" append-to-body :style="{ height: '500px' }">
-      <el-row :gutter="20" style="height: 100%; display: flex">
-        <el-col :span="10" style="height: 100%; overflow: auto; border-right: 1px solid #eee; padding-right: 10px">
+    <el-dialog :title="dialog.title" v-model="dialog.visible" append-to-body :style="{ maxWidth: '90vw', maxHeight: '90vh' }">
+      <el-row :gutter="20" style="display: flex;">
+        <el-col :span="10" style="height: 100%; overflow: auto; border-right: 1px solid #eee; padding-right: 10px;">
           <el-tree
             ref="sourceTreeRef"
             :data="sourceTreeOptions"
@@ -169,17 +169,22 @@
             :check-strictly="true"
             :default-expand-all="false"
             @check="handleTreeCheck"
+            style="height: 100%"
           ></el-tree>
         </el-col>
-        <el-col :span="14" style="height: 100%; overflow: auto; padding-left: 10px">
-          <el-form ref="minioDataFormRef" :model="form" :rules="rules" label-width="80px">
+        <el-col :span="14" style="height: 100%; display: flex; flex-direction: column; overflow: auto; padding-left: 10px;">
+          <el-form ref="minioDataFormRef" :model="form" :rules="rules" label-width="80px" style="flex: 1 1 auto; height: 100%; display: flex; flex-direction: column;">
             <el-form-item label="分类" prop="tag">
               <el-select v-model="form.tag" placeholder="请选择分类">
                 <el-option v-for="dict in smsb_source_classify" :key="dict.value" :label="dict.label" :value="parseInt(dict.value)"></el-option>
               </el-select>
             </el-form-item>
-            <el-form-item label="">
+            <el-form-item label="" style="flex: 1 1 auto; display: flex; flex-direction: column; justify-content: flex-start;">
+              <!-- 原始上传组件保留,供参考:
               <smsb-file-upload v-model="form.ossId" />
+              -->
+              <!-- 替换为新版上传组件 SmsbFileUploader -->
+              <SmsbFileUploader v-model="form.ossId" />
             </el-form-item>
           </el-form>
         </el-col>
@@ -262,6 +267,7 @@
 </template>
 
 <script setup name="MinioData" lang="ts">
+import SmsbFileUploader from '@/components/SmsbFileUpload/SmsbFileUploader.vue';
 import { listMinioData, getMinioData, delMinioData, addMinioData, updateMinioData, fileStatistics } from '@/api/smsb/source/minioData';
 import { MinioDataVO, MinioDataQuery, MinioDataForm } from '@/api/smsb/source/minioData_type';
 import { listSourceTree } from '@/api/smsb/source/sourceTree';
@@ -477,6 +483,11 @@ const handleUse = async (row?: MinioDataVO) => {
 
 /** 提交按钮 */
 const submitForm = () => {
+  // 若未选择文件,直接关闭弹窗,不做其它操作
+  if (!form.value.ossId) {
+    dialog.visible = false;
+    return;
+  }
   minioDataFormRef.value?.validate(async (valid: boolean) => {
     if (valid) {
       buttonLoading.value = true;