vue3+element-plus 组件功能实现 上传功能

发布于:2025-06-30 ⋅ 阅读:(19) ⋅ 点赞:(0)

一、整体功能概述
这段代码实现了一个基于 Vue 3 和 Element Plus 组件库的文件导入及预览功能模块。主要包含了一个主导入对话框(用于上传文件、展示文件相关信息、进行导入操作等)以及一个用于预览文件内容的预览对话框。支持导入特定格式(.xlsx、.csv)且大小不超过 10M 的文件,能展示导入数据的统计情况并提供预览功能,方便用户在正式导入前查看文件内容。
二、模板结构分析
部分
主导入对话框(el-dialog):
标题为 “导入”,通过v-model绑定dialogVisible控制显示隐藏,设置了固定宽度且禁止点击模态框关闭。
内部包含文件上传区域(el-upload组件),可拖放文件,限制了文件类型、自动上传行为等,在文件改变时触发handleFileChange方法。
展示了文件类型提示、下载模板提示及链接(点击调用handleDownloadTemplate方法)。
根据是否有上传文件以及上传进度等情况,动态展示文件预览相关信息、导入数据统计情况等内容。
对话框底部定义了 “取消” 和 “确定” 按钮,“确定” 按钮根据上传进度和文件列表情况控制是否可用,点击分别调用handleClose和handleSubmit方法。
预览对话框(el-dialog):
通过v-model绑定previewVisible控制显示隐藏,设置了标题、宽度、自定义模态框类等属性。
根据importResult.fileText的值(如’attr’或’alarm’),使用不同的el-table结构来展示预览数据,数据来源于previewData。
对话框底部有 “返回” 按钮,点击调用handlePreviewClose方法关闭预览对话框。

父页面

importVisible:一个ref类型的响应式变量,用于控制导入弹窗的显示与隐藏,初始值为false,当用户点击 “导入” 按钮时,会将其设置为true以显示导入弹窗。
file:同样是ref类型变量,用于存储用户选择要上传的文件对象,在后续的文件上传等操作中会使用到该文件。
importDialogRef:ref类型,用于获取导入对话框组件的引用,方便后续调用组件内的方法来更新导入结果等相关操作。
pre_import:ref类型,作为一个标志位,在文件预览等操作流程中起到控制作用,初始值为false,在特定逻辑中会被修改其值。
三、主要函数分析
handleImport函数
javascript
const handleImport = async () => {
try {
importVisible.value = true;
} catch (error) {
console.error(‘导入组件加载失败:’, error);
ElMessage.error(‘导入功能加载失败,请刷新页面重试’);
}
};
这个函数是用户点击 “导入” 按钮时触发的操作。它的主要目的是尝试显示导入弹窗,即将importVisible的值设为true。如果在这个过程中出现错误(比如导入组件加载异常),会在控制台打印错误信息,并通过ElMessage组件向用户提示导入功能加载失败,让用户刷新页面重试。
handleDownloadTemplate函数
javascript
const handleDownloadTemplate = async () => {
const res = await DeviceModelApi.downloadAlarmTemplate();
const blob = new Blob([res.data], {
type: ‘application/vnd.openxmlformats-officedocument.spreadsheetml.sheet’
});
const filename = 告警知识库导入模板_${new Date().getTime()}.xlsx;
const url = window.URL.createObjectURL(blob);
downloadFile(url, filename);

ElMessage.success(‘下载成功’);
};
此函数用于处理下载导入模板的操作。它首先调用后端DeviceModelApi的downloadAlarmTemplate方法获取模板数据,将返回的数据包装成Blob对象,设置好对应的文件类型(适用于 Excel 文件格式)。接着生成一个唯一的文件名(包含当前时间戳),创建一个临时的 URL 对象,然后通过downloadFile函数实现文件下载,最后向用户提示下载成功的消息。
handleFileUpload函数
javascript
const handleFileUpload = async (uploadFile) => {
file.value = uploadFile;
try {
const formData = new FormData();
formData.append(‘file’, uploadFile);
formData.append(‘clear_existing’, ‘true’);
formData.append(‘thing_model_id’, props.detailId);
formData.append(‘pre_import’, pre_import.value);

const res = await DeviceModelApi.importAlarms(formData);

let str = res.data.msg || '';
const successCount = Number(str.match(/成功导入(\d+)条/)?.[1] || 0);
const failCount = Number(str.match(/失败(\d+)条/)?.[1] || 0);
const totalCount = successCount + failCount;

let errorList = [];
if (failCount > 0) {
  errorList = res.data.data.error;
}

// 更新导入对话框的数据
importDialogRef.value?.updateImportResult({
  totalCount,
  successCount: Number(successCount),
  failCount: Number(failCount),
  errorList,
  fileText: 'alarm'
});

} catch (error) {
ElMessage.error(error.response?.data?.msg || error.message || ‘上传失败’);
}
};
该函数负责实际的文件上传操作,接收用户选择的文件对象作为参数。首先将传入的文件对象赋值给file.value以便后续使用。然后创建一个FormData对象,将文件以及其他相关参数(如是否清除现有数据、关联的模型 ID、预导入标志等)添加进去。接着调用后端DeviceModelApi的importAlarms方法进行文件上传,并处理返回结果:从返回消息中解析出成功导入和失败的记录数量,根据失败数量获取错误列表(如果有),最后通过导入对话框组件的引用调用updateImportResult方法更新导入对话框中显示的导入结果信息,包括总数、成功数、失败数、错误列表以及文件类型标识等内容。若上传过程出现错误,则向用户提示相应的错误消息。
handlePreview函数
javascript
const handlePreview = () => {
if (file.value) {
pre_import.value = true;
handleFileUpload(file.value)
.then(() => {
// 当handleFileUpload执行成功(Promise状态变为resolved)后,调用fetchList
return fetchList();
})
.catch((error) => {
ElMessage.error(error.response?.data?.msg || error.message || ‘文件上传或数据获取失败’);
});
}
};

这个函数用于文件预览功能。它首先判断是否已经选择了文件(即file.value是否有值),如果有文件,则将pre_import的值设为true,接着调用handleFileUpload函数进行文件上传操作。当handleFileUpload执行成功(Promise 状态变为resolved)后,会继续调用fetchList函数来获取相关数据(可能是用于展示预览内容的数据)。如果在整个过程中出现错误(文件上传或者获取数据失败),会通过ElMessage向用户提示相应的错误信息。

<ImportDialog
  ref="importDialogRef"
  v-model:visible="importVisible"
  @success="handleSearch"
  @download-template="handleDownloadTemplate"
  @submit="handleFileUpload"
  @preview="handlePreview"
/>
<template>
  <el-dialog
    title="导入"
    v-model="dialogVisible"
    width="600px"
    :close-on-click-modal="false"
    @close="handleClose"
  >
    <div class="upload-area">
      <el-upload
        class="upload-dragger"
        drag
        action="#"
        :auto-upload="false"
        :show-file-list="false"
        accept=".xlsx,.csv"
        :on-change="handleFileChange"
      >
        <div class="upload-content">
          <el-icon class="upload-icon" :size="80"><upload-filled /></el-icon>
          <div class="upload-text">
            <p>把文件拖放到此处或 <span class="upload-link">重新上传</span></p>
          </div>
        </div>
      </el-upload>
    </div>
    <p class="file-type-tip text-left">支持扩展名:.xlsx、.csv,文件大小不超过10M</p>
    <div class="download-tip text-left">
      下载导入模板,根据模板提示完善内容
      <el-link type="primary" @click="handleDownloadTemplate">下载模板</el-link>
    </div>
    <div v-if="fileList.length" class="file-preview">
      <div class="file-item">
        <div class="file-info">
          <div class="file-icon-wrapper">
            <span class="file-type-text">csv</span>
          </div>
          <span class="file-name">{{ fileList[0].name }}</span>
          <div class="file-actions">
            <el-icon v-if="uploadProgress === 100" class="success-icon" :size="24" color="#67C23A"
              ><circle-check
            /></el-icon>
            <el-link
              class="preview-link"
              type="primary"
              v-if="uploadProgress === 100"
              @click="handlePreview"
              >文件预览</el-link
            >
          </div>
        </div>
        <el-progress :percentage="uploadProgress" :show-text="false" class="upload-progress" />
        <div class="import-info" v-if="uploadProgress === 100">
          <p>共导入数据{{ importResult.totalCount }}条数据...</p>
          <p v-if="importResult.failCount > 0" class="error-text">
            错误数据{{ importResult.failCount }}...错误数据将无法导入!
          </p>
          <p v-if="importResult.successCount > 0">
            是否将本次 {{ importResult.successCount }} 条有效数据导入?
          </p>
          <p v-if="importResult.successCount == 0">
            本次0条有效数据,无有效数据无法导入,请重新上传文件!
          </p>
        </div>
      </div>
    </div>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button
          type="primary"
          @click="handleSubmit"
          :disabled="uploadProgress === 100 && fileList.length ? false : true"
          >确定</el-button
        >
      </div>
    </template>
  </el-dialog>

  <!-- 预览弹窗 -->
  <el-dialog
    v-model="previewVisible"
    title="文件预览"
    width="80%"
    :modal-class="'preview-dialog'"
    :close-on-click-modal="false"
    :before-close="handlePreviewClose"
    append-to-body
  >
    <div class="preview-content">
      <template v-if="importResult.fileText == 'attr'">
        <el-table
          :header-cell-style="{
            backgroundColor: '#F2F3F5',
            fontSize: '14px'
          }"
          :data="previewData"
          height="calc(100vh - 200px)"
          style="width: 100%"
        >
          <el-table-column prop="identifier" label="属性标识" width="180"></el-table-column>
          <el-table-column prop="name" label="属性名称" width="180"></el-table-column>
          <el-table-column prop="data_type" label="数据类型" width="180">
            <template #default="{ row }">
              <span>{{
                {
                  int: '整数',
                  float: '浮点数',
                  string: '字符串',
                  bool: '布尔值',
                  enum: '枚举'
                }[row.data_type]
              }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="unit" label="单位" width="180">
            <template #default="{ row }">
              <span>{{ ['int', 'float'].includes(row.data_type) ? row.unit || '' : '-' }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="precision" label="精度" width="180">
            <template #default="{ row }">
              <span>{{
                row.data_type === 'float'
                  ? row.precision
                    ? `小数点后${row.precision}`
                    : ''
                  : '-'
              }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="data_source" label="数据来源" width="180">
            <template #default="{ row }">
              <span>{{
                {
                  gateway: '数采网关',
                  rule_engine: '规则引擎'
                }[row.data_source]
              }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="rw_permission" label="读写权限" width="180">
            <template #default="{ row }">
              <span>{{
                {
                  r: '只读',
                  w: '只写',
                  rw: '读写'
                }[row.rw_permission]
              }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="description" label="属性描述" width="180"></el-table-column>
          <el-table-column label="错误类型" width="180">
            <template #default="scope">
              <span v-if="scope.row.error && scope.row.error.length > 0">{{
                scope.row.error[0].error
              }}</span>
              <span v-else>无错误</span>
            </template>
          </el-table-column>
        </el-table>
      </template>

      <template v-if="importResult.fileText == 'alarm'">
        <el-table
          :header-cell-style="{
            backgroundColor: '#F2F3F5',
            fontSize: '14px'
          }"
          :data="previewData"
          height="calc(100vh - 200px)"
          style="width: 100%"
        >
          <el-table-column prop="identifier" label="告警编码" ></el-table-column>
          <el-table-column prop="name" label="告警信息" ></el-table-column>
          <el-table-column prop="physical_name" label="关联部位" ></el-table-column>
          <el-table-column label="错误类型">
            <template #default="scope">
              <span v-if="scope.row.error && scope.row.error.length > 0">{{
                scope.row.error[0].error
              }}</span>
              <span v-else>无错误</span>
            </template>
          </el-table-column>
        </el-table>
      </template>
    </div>
    <template #footer>
      <div class="preview-footer">
        <el-button @click="handlePreviewClose">返回</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, watch } from 'vue'
import { Upload, CircleCheck } from '@element-plus/icons-vue'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  }
})

const importResult = ref({
  totalCount: 0,
  successCount: 0,
  failCount: 0,
  errorList: [],
  fileText: ''
})
// 更新导入结果
const updateImportResult = (result) => {
  importResult.value = result
  // 当获取到导入结果时,将进度条设置为 100%
  uploadProgress.value = 100
}
// 暴露方法给父组件
defineExpose({
  updateImportResult
})

const emit = defineEmits(['update:visible', 'success', 'download-template', 'submit', 'preview'])

const dialogVisible = ref(false)
const fileList = ref([])

// 监听弹窗显示状态
watch(
  () => props.visible,
  (val) => {
    dialogVisible.value = val
  },
  { immediate: true } // 添加 immediate 选项确保首次渲染时同步状态
)

const uploadProgress = ref(0)

// 文件变化
const handleFileChange = (file) => {
  // 检查文件大小
  const isLt10M = file.size / 1024 / 1024 < 10
  if (!isLt10M) {
    ElMessage.error('文件大小不能超过 10MB!')
    return
  }
  fileList.value = [file]
  uploadProgress.value = 0

  // 模拟上传进度到 90%
  const timer = setInterval(() => {
    if (uploadProgress.value < 90) {
      uploadProgress.value += 10
    } else {
      clearInterval(timer)
      // 触发父组件的事件,传递原始文件对象
      emit('submit', file.raw)
    }
  }, 500)
}

// 移除文件
const handleRemoveFile = (index) => {
  fileList.value.splice(index, 1)
}

// 下载模板
const handleDownloadTemplate = () => {
  emit('download-template')
}

// 关闭弹窗
const handleClose = () => {
  emit('update:visible', false)
  fileList.value = []
  uploadProgress.value = 0
  importResult.value = {
    totalCount: 0,
    successCount: 0,
    failCount: 0,
    errorList: [],
    fileText: ''
  }
  // emit('')
}

// 提交
const handleSubmit = () => {
  emit('preview')
  handleClose()
}

// 预览相关
const previewVisible = ref(false)
const previewData = ref([])

// 预览方法
const handlePreview = async () => {
  if (importResult.value.failCount == 0) return
  try {
    let data = importResult.value.errorList
    let tableData = data.map((item) => ({
      ...item.row,
      error: item.error
    }))

    previewData.value = tableData
    previewVisible.value = true
  } catch (error) {
    ElMessage.error('文件预览失败')
  }
}
// 关闭预览弹窗
const handlePreviewClose = () => {
  previewVisible.value = false
}
</script>

<style scoped lang="scss">
.upload-area {
  border: 1px dashed #dcdfe6;
  border-radius: 6px;
  text-align: center;

  .upload-dragger {
    :deep(.el-upload) {
      width: 100%;
    }

    :deep(.el-upload-dragger) {
      width: 100%;
      height: auto;
      border: none;
    }
  }

  .upload-content {
    display: flex;
    flex-direction: column;
    align-items: center;

    :deep(.upload-icon) {
      color: #c0c4cc;
      margin-bottom: 24px;
      svg {
        width: 80px;
        height: 80px;
      }
    }

    .upload-text {
      color: #606266;
      font-size: 14px;

      .upload-link {
        color: #409eff;
        cursor: pointer;
      }

      .upload-tip {
        font-size: 12px;
        color: #909399;
        margin-top: 12px;
      }
    }
  }
}

.text-left {
  text-align: left;
}

.file-type-tip {
  margin-top: 12px;
  font-size: 14px;
  color: #909399;
}

.download-tip {
  margin-top: 16px;
  font-size: 14px;
  color: #606266;
}

.file-preview {
  margin-top: 20px;
  padding: 16px;

  .file-item {
    .upload-progress {
      :deep(.el-progress-bar__outer) {
        background-color: #e9ecef;
        height: 4px !important;
        border-radius: 2px;
      }
      :deep(.el-progress-bar__inner) {
        transition: width 0.3s ease;
        border-radius: 2px;
        background-color: #409eff;
      }
      :deep(.el-progress__text) {
        font-size: 13px;
        color: #606266;
      }
    }

    .file-info {
      display: flex;
      align-items: center;
      gap: 12px;
      background: #f5f7fa;
      padding: 12px;
      border-radius: 4px;

      .file-icon-wrapper {
        display: flex;
        align-items: center;
        gap: 4px;
        background: #409eff;
        padding: 4px 8px;
        border-radius: 4px;
        color: white;

        .file-type-icon {
          font-size: 16px;
        }

        .file-type-text {
          font-size: 12px;
          text-transform: uppercase;
        }
      }

      .file-name {
        flex: 1;
        font-size: 14px;
        color: #606266;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }

      .file-actions {
        display: flex;
        align-items: center;
        gap: 12px;

        .success-icon {
          color: #67c23a;
        }

        .preview-link {
          font-size: 14px;
          color: #409eff;
          text-decoration: none;

          &:hover {
            opacity: 0.8;
          }
        }
      }
    }

    .import-info {
      margin-top: 10px;
      font-size: 14px;
      color: #606266;
      line-height: 1.8;

      .error-text {
        color: #f56c6c;
      }
    }
  }
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

.preview-dialog-container {
  :deep(.el-dialog) {
    position: fixed;
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%, -50%);
    margin: 0 !important;
    height: 100vh;
    max-height: 100vh;
    display: flex;
    flex-direction: column;
  }

  :deep(.el-dialog__body) {
    flex: 1;
    overflow: hidden;
    padding: 10px;
  }

  :deep(.el-dialog__footer) {
    padding: 10px 20px;
    border-width: 1px 0px 0px 0px;
    border-style: solid;
    border-color: #e5e6eb;
  }
}

.preview-content {
  height: 100%;
}

.preview-footer {
  text-align: right;
}
</style>


网站公告

今日签到

点亮在社区的每一天
去签到