Vue3 + Element Plus 实现大文件分片上传组件(支持秒传、断点续传)

前言

在实际项目开发中,我们经常会遇到需要上传大文件的场景。传统的文件上传方式在处理大文件时存在诸多问题:

  • 上传时间长:大文件上传耗时,用户体验差
  • 易失败:网络不稳定时容易导致上传失败
  • 无法续传:上传失败后需要重新上传整个文件
  • 服务器压力大:大文件一次性上传占用大量内存

本文将介绍如何使用 Vue3 + Element Plus 实现一个功能完善的大文件分片上传组件,支持以下特性:

分片上传 :将大文件切分成小块上传

秒传功能 :通过 MD5 校验实现文件秒传

断点续传 :上传失败后可从断点处继续

上传进度 :实时显示上传进度

文件校验 :支持文件类型、大小校验

拖拽排序:支持文件列表拖拽排序

技术栈

  • Vue 3
  • Element Plus
  • SparkMD5(计算文件 MD5)
  • Sortable.js(拖拽排序)

核心原理

1. 分片上传流程

复制代码
1. 选择文件
2. 计算文件 MD5 值
3. 检查文件是否已上传(秒传)
4. 将文件切分成多个分片
5. 逐个上传分片
6. 服务端合并分片
7. 返回文件地址

2. MD5 计算

使用 SparkMD5 库计算文件的 MD5 值,作为文件的唯一标识:

javascript 复制代码
function computeMD5(file) {
  return new Promise((resolve, reject) => {
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    const chunkSize = 2097152 // 2MB
    const chunks = Math.ceil(file.size / chunkSize)
    
    let currentChunk = 0
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()

    fileReader.onload = function (e) {
      spark.append(e.target.result)
      currentChunk++

      if (currentChunk < chunks) {
        loadNext()
      } else {
        resolve(spark.end())
      }
    }

    fileReader.onerror = () => reject('MD5 computation failed')

    function loadNext() {
      const start = currentChunk * chunkSize
      const end = Math.min(start + chunkSize, file.size)
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }

    loadNext()
  })
}

3. 文件分片

将大文件按指定大小切分成多个小块:

javascript 复制代码
function createChunks(file, chunkSize) {
  const chunks = []
  let cur = 0
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize))
    cur += chunkSize
  }
  return chunks
}

完整组件代码

ChunkUpload/index.vue

vue 复制代码
<template>
  <div class="upload-file">
    <el-upload
      multiple
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :show-file-list="false"
      :headers="headers"
      :http-request="customUpload"
      class="upload-file-uploader"
      ref="fileUpload"
      v-if="!disabled"
    >
      <!-- 上传按钮 -->
      <el-button v-if="!isUploadBtn" type="primary">选取文件</el-button>
      <slot v-else name="btn"></slot>
    </el-upload>
    
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip && !disabled">
      请上传
      <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
      <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
      的文件
    </div>
    
    <!-- 文件列表 -->
    <transition-group
      v-if="showTip"
      ref="uploadFileList"
      class="upload-file-list el-upload-list el-upload-list--text"
      name="el-fade-in-linear"
      tag="ul"
    >
      <li
        :key="file.uid"
        class="el-upload-list__item ele-upload-list__item-content"
        v-for="(file, index) in fileList"
      >
        <el-link :href="file.url" :underline="false" target="_blank">
          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
        </el-link>
        <div class="ele-upload-list__item-content-action">
          <el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">
            &nbsp;删除
          </el-link>
        </div>
        <div v-if="file.status === 'uploading'" class="upload-progress">
          <el-progress :percentage="file.progress || 0" />
        </div>
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { getToken } from "@/utils/auth"
import { uploadFileChunk, checkFileChunk } from '@/api/file/uploadFile'
import Sortable from 'sortablejs'
import SparkMD5 from 'spark-md5'

const props = defineProps({
  modelValue: [String, Object, Array],
  // 分片上传地址
  action: {
    type: String,
    default: "/file/uploadBig"
  },
  // 检查文件地址
  checkAction: {
    type: String,
    default: "/file/checkFile"
  },
  // 分片大小 (默认 5MB)
  chunkSize: {
    type: Number,
    default: 5 * 1024 * 1024
  },
  // 上传携带的参数
  data: {
    type: Object
  },
  // 数量限制
  limit: {
    type: Number,
    default: 5
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 50000
  },
  // 文件类型
  fileType: {
    type: Array,
    default: () => ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf"]
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
  // 禁用组件
  disabled: {
    type: Boolean,
    default: false
  },
  // 拖动排序
  drag: {
    type: Boolean,
    default: true
  },
  // 上传按钮使用插槽
  isUploadBtn: {
    type: Boolean,
    default: false
  },
  // 自定义返回参数
  customParams: {
    type: Boolean,
    default: false
  },
  // 是否支持重复上传
  isRepeat: {
    type: Boolean,
    default: false
  }
})

const { proxy } = getCurrentInstance()
const emit = defineEmits()
const number = ref(0)
const uploadList = ref([])
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action)
const baseCdnUrl = import.meta.env.VITE_APP_BASE_CDN
const headers = ref({ Authorization: "Bearer " + getToken() })
const fileList = ref([])
const showTip = computed(
  () => props.isShowTip && (props.fileType || props.fileSize)
)

watch(() => props.modelValue, val => {
  if (val && !props.isRepeat) {
    let temp = 1
    const list = Array.isArray(val) ? val : props.modelValue.split(',')
    fileList.value = list.map(item => {
      if (typeof item === "string") {
        item = { name: baseCdnUrl + item, url: baseCdnUrl + item }
      }
      item.uid = item.uid || new Date().getTime() + temp++
      return item
    })
  } else {
    fileList.value = []
    number.value = 0
    return []
  }
}, { deep: true, immediate: true })

// 上传前校检
function handleBeforeUpload(file) {
  // 校检文件类型
  if (props.fileType.length) {
    const fileName = file.name.split('.')
    const fileExt = fileName[fileName.length - 1]
    const isTypeOk = props.fileType.indexOf(fileExt) >= 0
    if (!isTypeOk) {
      proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}格式文件!`)
      return false
    }
  }
  // 校检文件名
  if (file.name.includes(',')) {
    proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
    return false
  }
  // 校检文件大小
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize
    if (!isLt) {
      proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
      return false
    }
  }
  proxy.$modal.loading("正在处理上传文件,请稍候...")
  number.value++
  return true
}

// 计算MD5
function computeMD5(file) {
  return new Promise((resolve, reject) => {
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    const chunkSize = 2097152 // 2MB
    const chunks = Math.ceil(file.size / chunkSize)
    
    let currentChunk = 0
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()

    fileReader.onload = function (e) {
      spark.append(e.target.result)
      currentChunk++

      if (currentChunk < chunks) {
        loadNext()
      } else {
        const md5 = spark.end()
        resolve(md5)
      }
    }

    fileReader.onerror = function () {
      reject('MD5 computation failed')
    }

    function loadNext() {
      const start = currentChunk * chunkSize
      const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }

    loadNext()
  })
}

// 自定义上传
async function customUpload(options) {
  const { file } = options
  
  // 初始化进度
  const fileIndex = fileList.value.findIndex(f => f.uid === file.uid)
  if (fileIndex === -1) {
    fileList.value.push({
      name: file.name,
      uid: file.uid,
      status: 'uploading',
      progress: 0
    })
  }

  try {
    // 1. 计算文件MD5
    const md5 = await computeMD5(file)
    
    // 2. 检查文件状态(秒传/断点续传)
    const checkRes = await checkFileChunk({ md5 })

    if (checkRes.code === 200 && checkRes.data && checkRes.data.relativePath) {
      // 秒传成功
      const successRes = {
        code: 200,
        msg: "操作成功",
        data: {
          relativePath: checkRes.data.relativePath,
          url: checkRes.data.relativePath
        }
      }
      updateFileProgress(file.uid, 100)
      handleUploadSuccess(successRes, file)
      return
    }

    // 获取已上传的分片状态
    const uploadedStatus = checkRes.data?.chucks || ""
    const chunks = createChunks(file, props.chunkSize)
    const totalChunks = chunks.length
    
    // 3. 逐个上传分片
    for (let i = 0; i < totalChunks; i++) {
      // 检查当前分片是否已上传
      if (uploadedStatus.length > i && uploadedStatus[i] === '1') {
        const progress = Math.ceil(((i + 1) / totalChunks) * 100)
        updateFileProgress(file.uid, progress)
        proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
        continue
      }
      
      let fileName = file.name
      const formData = new FormData()
      formData.append('file', new File([chunks[i]], fileName))
      formData.append('chunkNumber', i)
      formData.append('chunkSize', props.chunkSize)
      formData.append('totalNumber', totalChunks)
      formData.append('md5', md5)
      
      // 添加额外参数
      if (props.data) {
        Object.keys(props.data).forEach(key => {
          formData.append(key, props.data[key])
        })
      }

      const res = await uploadFileChunk(formData)
      
      if (res.code === 200) {
        if (i === totalChunks - 1) {
          updateFileProgress(file.uid, 100)
          handleUploadSuccess(res, file)
          proxy.$modal.msgSuccess('文件上传成功')
          return
        }
      } else {
        throw new Error(res.msg || '上传失败')
      }

      // 更新进度
      const progress = Math.ceil(((i + 1) / totalChunks) * 100)
      updateFileProgress(file.uid, progress)
      proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
    }
  } catch (err) {
    handleUploadError(err)
  }
}

// 创建分片
function createChunks(file, chunkSize) {
  const chunks = []
  let cur = 0
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize))
    cur += chunkSize
  }
  return chunks
}

// 更新文件进度
function updateFileProgress(uid, progress) {
  const item = fileList.value.find(f => f.uid === uid)
  if (item) {
    item.progress = progress
  }
}

// 文件个数超出
function handleExceed() {
  proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
}

// 上传失败
function handleUploadError(err) {
  console.error(err)
  proxy.$modal.msgError("上传文件失败")
  proxy.$modal.closeLoading()
  fileList.value = fileList.value.filter(f => f.status !== 'uploading')
  number.value--
}

// 上传成功回调
function handleUploadSuccess(res, file) {
  if (!res) return
  if (res.code === 200) {
    if (props.customParams) {
      emit('update:files', { url: res.data?.relativePath })
      proxy.$modal.closeLoading()
      return
    }
    const url = res.data.relativePath || res.data.url || res.data.path
    uploadList.value.push({ name: url, url: url })
    uploadedSuccessfully()
  } else {
    number.value--
    proxy.$modal.closeLoading()
    proxy.$modal.msgError(res.msg)
    uploadedSuccessfully()
  }
}

// 删除文件
function handleDelete(index) {
  fileList.value.splice(index, 1)
  emit("update:modelValue", listToString(fileList.value))
}

// 上传结束处理
function uploadedSuccessfully() {
  if (number.value > 0 && uploadList.value.length === number.value) {
    fileList.value = fileList.value.filter(f => f.status !== 'uploading').concat(uploadList.value)
    uploadList.value = []
    number.value = 0
    emit("update:modelValue", listToString(fileList.value))
    proxy.$modal.closeLoading()
  }
}

// 获取文件名称
function getFileName(name) {
  if (name.lastIndexOf("/") > -1) {
    return name.slice(name.lastIndexOf("/") + 1)
  } else {
    return name
  }
}

// 对象转成指定字符串分隔
function listToString(list, separator) {
  let strs = ""
  separator = separator || ","
  for (let i in list) {
    if (list[i].url) {
      strs += list[i].url + separator
    }
  }
  return strs != '' ? strs.substr(0, strs.length - 1) : ''
}

// 初始化拖拽排序
onMounted(() => {
  if (props.drag && !props.disabled) {
    nextTick(() => {
      const element = proxy.$refs.uploadFileList?.$el || proxy.$refs.uploadFileList
      if (element) {
        Sortable.create(element, {
          ghostClass: 'file-upload-darg',
          onEnd: (evt) => {
            const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
            fileList.value.splice(evt.newIndex, 0, movedItem)
            emit('update:modelValue', listToString(fileList.value))
          }
        })
      }
    })
  }
})
</script>

<style scoped lang="scss">
.file-upload-darg {
  opacity: 0.5;
  background: #c8ebfb;
}

.upload-file-uploader {
  margin-bottom: 5px;
}

.upload-file-list .el-upload-list__item {
  border: 1px solid #e4e7ed;
  line-height: 2;
  margin-bottom: 10px;
  position: relative;
  transition: none !important;
}

.upload-file-list .ele-upload-list__item-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: inherit;
  flex-wrap: wrap;
}

.ele-upload-list__item-content-action .el-link {
  margin-right: 10px;
}

.upload-progress {
  width: 100%;
  padding: 0 10px;
  margin-top: 5px;
}
</style>

API 接口定义

uploadFile.js

javascript 复制代码
import request from '@/utils/request'

// 分片上传
export function uploadFileChunk(data) {
  return request({
    url: '/file/uploadBig',
    method: 'post',
    data: data,
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  })
}

// 检查文件状态
export function checkFileChunk(params) {
  return request({
    url: '/file/checkFile',
    method: 'get',
    params: params
  })
}

使用示例

基础用法

vue 复制代码
<template>
  <div>
    <ChunkUpload 
      v-model="fileList"
      :chunk-size="5 * 1024 * 1024"
      :file-size="500"
      :file-type="['pdf', 'doc', 'docx']"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'

const fileList = ref('')
</script>

自定义上传按钮

vue 复制代码
<template>
  <ChunkUpload 
    v-model="fileList"
    :is-upload-btn="true"
  >
    <template #btn>
      <el-button type="success" icon="Upload">
        点击上传大文件
      </el-button>
    </template>
  </ChunkUpload>
</template>

表单中使用

vue 复制代码
<template>
  <el-form :model="form" ref="formRef">
    <el-form-item label="附件上传" prop="attachments">
      <ChunkUpload 
        v-model="form.attachments"
        :limit="3"
        :file-size="1000"
        :chunk-size="10 * 1024 * 1024"
      />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="submitForm">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'

const formRef = ref()
const form = ref({
  attachments: ''
})

const submitForm = () => {
  formRef.value.validate((valid) => {
    if (valid) {
      console.log('提交的附件:', form.value.attachments)
      // 提交表单逻辑
    }
  })
}
</script>

Props 参数说明

参数 说明 类型 默认值
modelValue 文件列表(v-model) String/Array/Object -
action 分片上传接口地址 String '/file/uploadBig'
checkAction 检查文件接口地址 String '/file/checkFile'
chunkSize 分片大小(字节) Number 5242880 (5MB)
data 上传时附带的额外参数 Object -
limit 最大上传文件数 Number 5
fileSize 文件大小限制(MB) Number 50000
fileType 允许的文件类型 Array ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'pdf']
isShowTip 是否显示提示信息 Boolean true
disabled 是否禁用(只读模式) Boolean false
drag 是否支持拖拽排序 Boolean true
isUploadBtn 是否使用自定义上传按钮 Boolean false
customParams 是否自定义返回参数 Boolean false
isRepeat 是否支持重复上传 Boolean false

Events 事件

事件名 说明 回调参数
update:modelValue 文件列表变化时触发 (fileList: string)
update:files 自定义参数模式下触发 ({ url: string })

服务端接口要求

1. 检查文件接口 (GET /file/checkFile)

请求参数:

javascript 复制代码
{
  md5: string  // 文件的 MD5 值
}

返回格式:

javascript 复制代码
{
  code: 200,
  msg: "操作成功",
  data: {
    relativePath: string,  // 如果文件已存在,返回文件路径(秒传)
    chucks: string         // 已上传的分片状态,如 "111000" 表示前3个分片已上传
  }
}

2. 分片上传接口 (POST /file/uploadBig)

请求参数(FormData):

javascript 复制代码
{
  file: File,           // 分片文件
  chunkNumber: number,  // 当前分片索引(从0开始)
  chunkSize: number,    // 分片大小
  totalNumber: number,  // 总分片数
  md5: string          // 文件 MD5 值
}

返回格式:

javascript 复制代码
{
  code: 200,
  msg: "操作成功",
  data: {
    relativePath: string,  // 文件完整路径(最后一个分片上传成功时返回)
    url: string           // 文件访问地址
  }
}

服务端实现示例(Java Spring Boot)

java 复制代码
@RestController
@RequestMapping("/file")
public class FileUploadController {
    
    @Autowired
    private FileService fileService;
    
    /**
     * 检查文件状态
     */
    @GetMapping("/checkFile")
    public AjaxResult checkFile(@RequestParam String md5) {
        // 1. 检查文件是否已存在(秒传)
        String filePath = fileService.getFileByMd5(md5);
        if (StringUtils.isNotEmpty(filePath)) {
            return AjaxResult.success(Map.of("relativePath", filePath));
        }
        
        // 2. 检查已上传的分片
        String chucks = fileService.getUploadedChunks(md5);
        return AjaxResult.success(Map.of("chucks", chucks));
    }
    
    /**
     * 分片上传
     */
    @PostMapping("/uploadBig")
    public AjaxResult uploadBig(
        @RequestParam("file") MultipartFile file,
        @RequestParam("chunkNumber") Integer chunkNumber,
        @RequestParam("chunkSize") Long chunkSize,
        @RequestParam("totalNumber") Integer totalNumber,
        @RequestParam("md5") String md5
    ) throws IOException {
        // 1. 保存分片
        String chunkPath = fileService.saveChunk(file, md5, chunkNumber);
        
        // 2. 检查是否所有分片都已上传
        if (fileService.isAllChunksUploaded(md5, totalNumber)) {
            // 3. 合并分片
            String filePath = fileService.mergeChunks(md5, totalNumber);
            
            // 4. 保存文件记录
            fileService.saveFileRecord(md5, filePath);
            
            return AjaxResult.success(Map.of(
                "relativePath", filePath,
                "url", filePath
            ));
        }
        
        return AjaxResult.success();
    }
}

核心功能详解

1. 秒传功能

通过计算文件的 MD5 值,在上传前先检查服务器是否已存在相同的文件。如果存在,直接返回文件地址,无需重新上传。

优势:

  • 节省带宽和时间
  • 减轻服务器压力
  • 提升用户体验

2. 断点续传

上传过程中如果网络中断或页面刷新,再次上传时会检查已上传的分片,只上传未完成的部分。

实现原理:

  1. 服务端记录每个文件(通过 MD5 标识)已上传的分片
  2. 客户端上传前先查询已上传的分片状态
  3. 跳过已上传的分片,只上传剩余部分

3. 进度显示

实时显示上传进度,让用户了解上传状态。

javascript 复制代码
// 计算进度百分比
const progress = Math.ceil(((currentChunk + 1) / totalChunks) * 100)
updateFileProgress(file.uid, progress)

4. 文件校验

上传前进行文件类型、大小、文件名等校验,避免无效上传。

javascript 复制代码
// 文件类型校验
const fileExt = file.name.split('.').pop()
const isTypeOk = props.fileType.indexOf(fileExt) >= 0

// 文件大小校验
const isLt = file.size / 1024 / 1024 < props.fileSize

性能优化建议

1. 合理设置分片大小

  • 小文件(< 10MB):不建议使用分片上传
  • 中等文件(10MB - 100MB):建议 5MB 分片
  • 大文件(> 100MB):建议 10MB 分片

2. 并发上传

可以修改代码支持多个分片并发上传,提升上传速度:

javascript 复制代码
// 并发上传示例
const concurrency = 3 // 并发数
const uploadPromises = []

for (let i = 0; i < totalChunks; i += concurrency) {
  const batch = []
  for (let j = 0; j < concurrency && i + j < totalChunks; j++) {
    batch.push(uploadChunk(i + j))
  }
  await Promise.all(batch)
}

3. 服务端优化

  • 使用临时目录存储分片
  • 合并完成后删除分片文件
  • 使用消息队列异步处理合并操作
  • 定期清理过期的分片文件

常见问题

1. 上传大文件时浏览器卡顿

原因: MD5 计算占用大量 CPU
解决: 使用 Web Worker 在后台线程计算 MD5

2. 分片上传失败

原因: 网络不稳定或服务器超时
解决: 添加重试机制,失败的分片自动重试

3. 文件合并失败

原因: 分片顺序错误或分片丢失
解决: 服务端严格校验分片完整性

总结

本文介绍了一个功能完善的 Vue3 大文件分片上传组件,支持秒传、断点续传、进度显示等特性。通过合理的分片策略和 MD5 校验,可以大大提升大文件上传的成功率和用户体验。

核心要点:

  1. 使用 SparkMD5 计算文件唯一标识
  2. 通过 File.slice() 实现文件分片
  3. 服务端记录分片状态,支持断点续传
  4. 实时显示上传进度,提升用户体验

希望这篇文章能帮助你在项目中实现高效的大文件上传功能!

依赖安装

bash 复制代码
# 安装 SparkMD5
npm install spark-md5

# 安装 Sortable.js(如需拖拽排序)
npm install sortablejs

如果觉得这个组件对你有帮助,欢迎 Star ⭐


相关推荐
啥都不懂的小小白2 小时前
Vue Ajax与状态管理完全指南:从数据请求到全局状态控制
vue.js·ajax·vuex·插槽系统
多看书少吃饭2 小时前
文件预览的正确做法:从第三方依赖到企业级自建方案(Vue + Java 实战)
java·前端·vue.js
Amumu121382 小时前
Vue核心(一)
前端·javascript·vue.js
敲敲了个代码2 小时前
React 官方纪录片观后:核心原理解析与来龙去脉
前端·javascript·react.js·面试·架构·前端框架
一直都在5722 小时前
Spring3整合MyBatis实现增删改查操作
前端·vue.js·mybatis
研☆香2 小时前
JavaScript 历史列表查询的方法
开发语言·javascript·ecmascript
钟佩颖2 小时前
Vue....
前端·javascript·vue.js
漂流瓶jz2 小时前
Polyfill方式解决前端兼容性问题:core-js包结构与各种配置策略
前端·javascript·webpack·ecmascript·babel·polyfill·core-js
AC赳赳老秦2 小时前
低代码开发中的高效调试:基于 DeepSeek 的报错日志解析与自动修复方案生成
前端·javascript·低代码·postgresql·数据库架构·easyui·deepseek