mirror of
https://gitee.com/xiangheng/x_admin.git
synced 2025-10-18 14:10:52 +08:00
文件分片上传
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"pinia": "^3.0.3",
|
||||
"query-string": "^9.1.1",
|
||||
"rolldown-vite": "^7.0.9",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vform3-builds": "^3.0.10",
|
||||
"vue": "^3.5.16",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
|
191
admin/src/utils/FileUploader.ts
Normal file
191
admin/src/utils/FileUploader.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import SparkMD5 from 'spark-md5'
|
||||
import axios from 'axios'
|
||||
|
||||
export interface FileUploaderOptions {
|
||||
chunkSize?: number
|
||||
fileName?: string
|
||||
onSuccess?: (filePath: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export default class FileUploader {
|
||||
file: File
|
||||
fileMd5: string
|
||||
fileName: string
|
||||
chunkSize: number = 1024 * 1024 // 1MB
|
||||
chunkCount: number = 0
|
||||
onSuccess: FileUploaderOptions['onSuccess'] = () => {}
|
||||
onError: FileUploaderOptions['onError'] = () => {}
|
||||
onUploadProgress(chunkIndex, percent) {
|
||||
console.log(`当前分片: ${chunkIndex},进度: ${percent}%`)
|
||||
}
|
||||
|
||||
constructor(file: File, options: FileUploaderOptions) {
|
||||
this.file = file
|
||||
this.fileName = file.name
|
||||
|
||||
if (options?.chunkSize) {
|
||||
this.chunkSize = options.chunkSize
|
||||
}
|
||||
this.chunkCount = Math.ceil(this.file.size / this.chunkSize)
|
||||
if (options?.fileName) {
|
||||
this.fileName = options.fileName
|
||||
}
|
||||
if (options?.onSuccess) {
|
||||
this.onSuccess = options.onSuccess
|
||||
}
|
||||
if (options?.onError) {
|
||||
this.onError = options.onError
|
||||
}
|
||||
}
|
||||
|
||||
readerFile(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
return reject(new Error('文件不存在'))
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target?.result as ArrayBuffer)
|
||||
}
|
||||
reader.onerror = (e) => {
|
||||
reject(e)
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
// 开始/恢复上传
|
||||
async start() {
|
||||
try {
|
||||
const arrayBuffer = await this.readerFile(this.file)
|
||||
this.fileMd5 = this.getMd5(arrayBuffer)
|
||||
const isExistFilePath = await this.checkFileExist()
|
||||
if (isExistFilePath) {
|
||||
this.complete(isExistFilePath)
|
||||
return
|
||||
}
|
||||
this.splitChunks()
|
||||
} catch (error) {
|
||||
this.onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传完成
|
||||
complete(filePath) {
|
||||
/* 合并分片 */
|
||||
console.log('complete:', filePath)
|
||||
this.onSuccess(filePath)
|
||||
}
|
||||
// 检查上传状态
|
||||
getMd5(arrayBuffer: ArrayBuffer): string {
|
||||
const spark = new SparkMD5.ArrayBuffer()
|
||||
spark.append(arrayBuffer)
|
||||
return spark.end()
|
||||
}
|
||||
// 检查文件是否存在,可实现秒传
|
||||
async checkFileExist(): Promise<string> {
|
||||
try {
|
||||
/* 检查文件是否存在 */
|
||||
const res = await axios.get('/api/admin/common/uploadChunk/CheckFileExist', {
|
||||
params: {
|
||||
fileMd5: this.fileMd5,
|
||||
fileName: this.file.name
|
||||
}
|
||||
})
|
||||
console.log('Init', res)
|
||||
|
||||
if (res.data.code === 200 && res.data.data) {
|
||||
return res.data.data
|
||||
}
|
||||
return ''
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
async splitChunks() {
|
||||
for (let index = 0; index < this.chunkCount; index++) {
|
||||
const chunkStart = index * this.chunkSize
|
||||
const chunkEnd = Math.min(chunkStart + this.chunkSize, this.file.size)
|
||||
const chunk = this.file.slice(chunkStart, chunkEnd)
|
||||
await this.uploadChunk(this.fileMd5, index, chunk)
|
||||
}
|
||||
await this.mergeChunk()
|
||||
}
|
||||
async uploadChunk(fileMd5: string, index: number, chunk: Blob) {
|
||||
try {
|
||||
const checkResult = await axios.get('/api/admin/common/uploadChunk/CheckChunkExist', {
|
||||
params: {
|
||||
index,
|
||||
fileMd5
|
||||
}
|
||||
})
|
||||
console.log('checkResult', checkResult)
|
||||
|
||||
if (checkResult.data.code === 200) {
|
||||
console.log(`分片 ${index + 1}/${this.chunkCount} 已存在`)
|
||||
return
|
||||
} else if (checkResult.data.code === 500) {
|
||||
const formData = new FormData()
|
||||
formData.append('chunk', chunk)
|
||||
formData.append('index', String(index))
|
||||
formData.append('fileMd5', fileMd5)
|
||||
const result = await axios.post(
|
||||
'/api/admin/common/uploadChunk/UploadChunk',
|
||||
formData,
|
||||
{
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
this.onUploadProgress(index, percentCompleted)
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log('result', result)
|
||||
|
||||
if (result.data.code === 200) {
|
||||
console.log(`分片 ${index + 1}/${this.chunkCount} 上传成功`)
|
||||
} else {
|
||||
console.error(`分片 ${index + 1}/${this.chunkCount} 上传失败: ${result}`)
|
||||
// break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`分片 ${index + 1}/${this.chunkCount} 上传失败: ${error}`)
|
||||
this.onError(error)
|
||||
}
|
||||
}
|
||||
async mergeChunk() {
|
||||
try {
|
||||
const res = await axios.post('/api/admin/common/uploadChunk/MergeChunk', {
|
||||
fileMd5: this.fileMd5,
|
||||
fileName: this.file.name,
|
||||
chunkCount: this.chunkCount
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
console.log('合并分片成功')
|
||||
this.complete(res.data.data)
|
||||
} else {
|
||||
console.log('MergeChunk', res)
|
||||
this.onError(new Error('合并分片失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`合并分片失败: ${error}`)
|
||||
this.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* const uploader = new FileUploader(file, {
|
||||
endpoint: "/api/upload",
|
||||
concurrency: 4,
|
||||
onProgress: (percent, chunkIndex) => {
|
||||
console.log(`进度: ${percent}%,当前分片: ${chunkIndex}`);
|
||||
},
|
||||
onComplete: (fileUrl) => {
|
||||
console.log("文件地址:", fileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
*/
|
42
admin/src/views/error/uploadChunk.vue
Normal file
42
admin/src/views/error/uploadChunk.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<input type="file" ref="fileInput" @change="handleChange" />
|
||||
<el-button @click="btn">上传</el-button>
|
||||
<el-button @click="merge">合并</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FileUploader from '@/utils/FileUploader'
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
let fileUploader: FileUploader | null = null
|
||||
function handleChange(e) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
// console.log('e.target', e.target)
|
||||
console.log('files', files)
|
||||
if (files) {
|
||||
fileUploader = new FileUploader(files[0], { chunkSize: 1024 * 1024 * 1 })
|
||||
}
|
||||
}
|
||||
function btn() {
|
||||
fileUploader.start()
|
||||
}
|
||||
function merge() {
|
||||
fileUploader.mergeChunk()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
color: #fff;
|
||||
background-color: rgb(150, 149, 149);
|
||||
border: 0;
|
||||
padding: 6px 10px;
|
||||
margin: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
97
server/controller/admin_ctl/commonController/uploadChunk.go
Normal file
97
server/controller/admin_ctl/commonController/uploadChunk.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package commonController
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"x_admin/core/response"
|
||||
"x_admin/service/commonService"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UploadChunkRoute(rg *gin.RouterGroup) {
|
||||
handle := uploadChunkHandler{
|
||||
uploadPath: "./uploads",
|
||||
tmpPath: "./tmp",
|
||||
}
|
||||
os.MkdirAll(handle.uploadPath, 0755)
|
||||
os.MkdirAll(handle.tmpPath, 0755)
|
||||
|
||||
rg = rg.Group("/common")
|
||||
rg.GET("/uploadChunk/CheckFileExist", handle.CheckFileExist)
|
||||
rg.GET("/uploadChunk/CheckChunkExist", handle.CheckChunkExist)
|
||||
rg.POST("/uploadChunk/UploadChunk", handle.UploadChunk)
|
||||
rg.POST("/uploadChunk/MergeChunk", handle.MergeChunk)
|
||||
}
|
||||
|
||||
type uploadChunkHandler struct {
|
||||
uploadPath string
|
||||
tmpPath string
|
||||
}
|
||||
|
||||
func (uh uploadChunkHandler) CheckFileExist(c *gin.Context) {
|
||||
var fileMd5 = c.Query("fileMd5")
|
||||
var fileName = url.QueryEscape(c.Query("fileName"))
|
||||
// 正则检查MD5
|
||||
reg := regexp.MustCompile(`^[a-fA-F0-9]{32}$`)
|
||||
if !reg.MatchString(fileMd5) {
|
||||
response.FailWithMsg(c, response.SystemError, "MD5格式错误")
|
||||
return
|
||||
}
|
||||
var filePath = fmt.Sprintf("%s/%s_%s", uh.uploadPath, fileMd5, fileName)
|
||||
// 检查文件是否存在
|
||||
if commonService.UploadChunkService.CheckFileExist(filePath) {
|
||||
response.OkWithData(c, filePath)
|
||||
return
|
||||
}
|
||||
response.OkWithData(c, nil)
|
||||
}
|
||||
|
||||
// 检查chunk是否存在
|
||||
func (uh uploadChunkHandler) CheckChunkExist(c *gin.Context) {
|
||||
fileMd5 := c.Query("fileMd5") // 上传文件的md5
|
||||
index := c.Query("index") // 分片序号
|
||||
chunkPath := fmt.Sprintf("%s/%s/%s", uh.tmpPath, fileMd5, index)
|
||||
if commonService.UploadChunkService.CheckFileExist(chunkPath) {
|
||||
response.Ok(c)
|
||||
return
|
||||
}
|
||||
response.FailWithMsg(c, response.SystemError, "分片不存在")
|
||||
}
|
||||
|
||||
// UploadChunk 上传分片
|
||||
func (uh uploadChunkHandler) UploadChunk(c *gin.Context) {
|
||||
chunk, _ := c.FormFile("chunk") // 分片文件
|
||||
index := c.PostForm("index") // 分片序号
|
||||
fileMd5 := c.PostForm("fileMd5") // 上传文件的md5
|
||||
|
||||
chunkDir := fmt.Sprintf("%s/%s", uh.tmpPath, fileMd5)
|
||||
chunkPath := fmt.Sprintf("%s/%s", chunkDir, index)
|
||||
err := commonService.UploadChunkService.UploadChunk(chunkDir, chunkPath, chunk)
|
||||
if err != nil {
|
||||
response.FailWithMsg(c, response.SystemError, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c)
|
||||
}
|
||||
func (uh uploadChunkHandler) MergeChunk(c *gin.Context) {
|
||||
var MergeChunk struct {
|
||||
FileMd5 string `json:"fileMd5"` // 上传文件的md5
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
ChunkCount int `json:"chunkCount"` // 分片数量
|
||||
}
|
||||
bindErr := c.ShouldBindJSON(&MergeChunk)
|
||||
if bindErr != nil {
|
||||
response.FailWithMsg(c, response.SystemError, bindErr.Error())
|
||||
return
|
||||
}
|
||||
var filePath = fmt.Sprintf("%s/%s_%s", uh.uploadPath, MergeChunk.FileMd5, url.QueryEscape(MergeChunk.FileName))
|
||||
err := commonService.UploadChunkService.MergeChunk(MergeChunk.FileMd5, filePath, MergeChunk.ChunkCount)
|
||||
if err != nil {
|
||||
response.FailWithMsg(c, response.SystemError, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkWithData(c, filePath)
|
||||
}
|
@@ -16,6 +16,7 @@ func RegisterRoute(rg *gin.RouterGroup) {
|
||||
// 所有子路由需要加上前缀 /api/admin
|
||||
|
||||
commonController.UploadRoute(rg)
|
||||
commonController.UploadChunkRoute(rg)
|
||||
commonController.AlbumRoute(rg)
|
||||
commonController.IndexRoute(rg)
|
||||
|
||||
|
72
server/service/commonService/uploadChunkService.go
Normal file
72
server/service/commonService/uploadChunkService.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commonService
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"x_admin/util"
|
||||
)
|
||||
|
||||
var UploadChunkService = NewUploadChunkService()
|
||||
|
||||
// NewUploadService 初始化
|
||||
func NewUploadChunkService() *uploadChunkService {
|
||||
return &uploadChunkService{}
|
||||
}
|
||||
|
||||
// uploadService 上传服务实现类
|
||||
type uploadChunkService struct{}
|
||||
|
||||
// CheckFileExist 检查文件是否存在
|
||||
func (upSrv uploadChunkService) CheckFileExist(filePath string) bool {
|
||||
return util.ToolsUtil.IsFileExist(filePath)
|
||||
}
|
||||
|
||||
func (upSrv uploadChunkService) UploadChunk(chunkDir string, chunkPath string, chunk *multipart.FileHeader) error {
|
||||
os.MkdirAll(chunkDir, 0755)
|
||||
|
||||
chunkData, err := chunk.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer chunkData.Close()
|
||||
|
||||
chunkBytes, err := io.ReadAll(chunkData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(chunkPath, chunkBytes, 0644)
|
||||
}
|
||||
func (upSrv uploadChunkService) MergeChunk(fileMd5, filePath string, chunkCount int) error {
|
||||
|
||||
chunkDir := fmt.Sprintf("./tmp/%s", fileMd5)
|
||||
|
||||
chunks, err := os.ReadDir(chunkDir)
|
||||
if err != nil {
|
||||
return errors.New("分片不存在")
|
||||
}
|
||||
// 判断切片数量是否一致
|
||||
if len(chunks) != chunkCount {
|
||||
return errors.New("分片未全部上传,请重试")
|
||||
}
|
||||
mergedFile, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mergedFile.Close()
|
||||
for index := range chunkCount {
|
||||
chunkData, err := os.ReadFile(filepath.Join(chunkDir, fmt.Sprintf("%d", index)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = mergedFile.Write(chunkData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.RemoveAll(chunkDir) // 清理临时分片
|
||||
}
|
@@ -92,6 +92,24 @@ func (tu toolsUtil) ObjToJson(data interface{}) (res string, err error) {
|
||||
|
||||
// IsFileExist 判断文件或目录是否存在
|
||||
func (tu toolsUtil) IsFileExist(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
var root, err = os.OpenRoot(".")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer root.Close()
|
||||
_, err = root.Stat(path)
|
||||
return err == nil || os.IsExist(err)
|
||||
}
|
||||
|
||||
// 创建文件夹
|
||||
func (tu toolsUtil) CreateDir(path string) error {
|
||||
var root, err = os.OpenRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
return root.Mkdir(path, 0755)
|
||||
}
|
||||
func (tu toolsUtil) WriteFile(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
Reference in New Issue
Block a user