mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-03-13 17:24:44 +08:00
feat: 优化大文件下载 (#1183)
Refs https://github.com/1Panel-dev/1Panel/issues/1165
This commit is contained in:
parent
b5093e4d93
commit
800f9e2d38
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@ -454,17 +455,98 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
|
||||
// @Router /files/download [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
|
||||
func (b *BaseApi) Download(c *gin.Context) {
|
||||
var req request.FileDownload
|
||||
filePath := c.Query("path")
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
}
|
||||
|
||||
info, _ := file.Stat()
|
||||
|
||||
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
|
||||
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
||||
return
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
// @Summary Chunk Download file
|
||||
// @Description 分片下载下载文件
|
||||
// @Accept json
|
||||
// @Param request body request.FileDownload true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /files/chunkdownload [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
|
||||
func (b *BaseApi) DownloadChunkFiles(c *gin.Context) {
|
||||
var req request.FileChunkDownload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||
return
|
||||
}
|
||||
filePath, err := fileService.FileDownload(req)
|
||||
fileOp := files.NewFileOp()
|
||||
if !fileOp.Stat(req.Path) {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrPathNotFound, nil)
|
||||
return
|
||||
}
|
||||
filePath := req.Path
|
||||
fstFile, err := fileOp.OpenFile(filePath)
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
c.File(filePath)
|
||||
info, err := fstFile.Stat()
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrFileDownloadDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", req.Name))
|
||||
c.Writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
c.Writer.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
c.Writer.Header().Set("Accept-Ranges", "bytes")
|
||||
|
||||
if c.Request.Header.Get("Range") != "" {
|
||||
rangeHeader := c.Request.Header.Get("Range")
|
||||
rangeArr := strings.Split(rangeHeader, "=")[1]
|
||||
rangeParts := strings.Split(rangeArr, "-")
|
||||
|
||||
startPos, _ := strconv.ParseInt(rangeParts[0], 10, 64)
|
||||
|
||||
var endPos int64
|
||||
if rangeParts[1] == "" {
|
||||
endPos = info.Size() - 1
|
||||
} else {
|
||||
endPos, _ = strconv.ParseInt(rangeParts[1], 10, 64)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startPos, endPos, info.Size()))
|
||||
c.Writer.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
buffer := make([]byte, 1024*1024)
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
file.Seek(startPos, 0)
|
||||
reader := io.LimitReader(file, endPos-startPos+1)
|
||||
_, err = io.CopyBuffer(c.Writer, reader, buffer)
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.File(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
|
@ -82,6 +82,11 @@ type FileDownload struct {
|
||||
Compress bool `json:"compress" validate:"required"`
|
||||
}
|
||||
|
||||
type FileChunkDownload struct {
|
||||
Path string `json:"path" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
type DirSizeReq struct {
|
||||
Path string `json:"path" validate:"required"`
|
||||
}
|
||||
|
@ -84,6 +84,7 @@ var (
|
||||
ErrLinkPathNotFound = "ErrLinkPathNotFound"
|
||||
ErrFileIsExit = "ErrFileIsExit"
|
||||
ErrFileUpload = "ErrFileUpload"
|
||||
ErrFileDownloadDir = "ErrFileDownloadDir"
|
||||
)
|
||||
|
||||
// mysql
|
||||
|
@ -40,6 +40,7 @@ ErrMovePathFailed: "The target path cannot contain the original path!"
|
||||
ErrLinkPathNotFound: "Target path does not exist!"
|
||||
ErrFileIsExit: "File already exists!"
|
||||
ErrFileUpload: "Failed to upload file {{.name}} {{.detail}}"
|
||||
ErrFileDownloadDir: "Download folder not supported"
|
||||
|
||||
#website
|
||||
ErrDomainIsExist: "Domain is already exist"
|
||||
|
@ -40,6 +40,7 @@ ErrMovePathFailed: "目标路径不能包含原路径!"
|
||||
ErrLinkPathNotFound: "目标路径不存在!"
|
||||
ErrFileIsExit: "文件已存在!"
|
||||
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail}}"
|
||||
ErrFileDownloadDir: "不支持下载文件夹"
|
||||
|
||||
#website
|
||||
ErrDomainIsExist: "域名已存在"
|
||||
|
@ -1,11 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/gzip"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
"github.com/1Panel-dev/1Panel/backend/i18n"
|
||||
"github.com/1Panel-dev/1Panel/backend/middleware"
|
||||
@ -18,23 +17,16 @@ import (
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func setWebStatic(rootRouter *gin.Engine) {
|
||||
func setWebStatic(rootRouter *gin.RouterGroup) {
|
||||
rootRouter.StaticFS("/fav", http.FS(web.Favicon))
|
||||
rootRouter.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
staticServer := http.FileServer(http.FS(web.Assets))
|
||||
staticServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
rootRouter.GET("/", func(c *gin.Context) {
|
||||
staticServer := http.FileServer(http.FS(web.IndexHtml))
|
||||
staticServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
rootRouter.NoRoute(func(c *gin.Context) {
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(web.IndexByte)
|
||||
c.Writer.Header().Add("Accept", "text/html")
|
||||
c.Writer.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
func Routers() *gin.Engine {
|
||||
@ -45,8 +37,14 @@ func Routers() *gin.Engine {
|
||||
if global.CONF.System.IsDemo {
|
||||
Router.Use(middleware.DemoHandle())
|
||||
}
|
||||
Router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
setWebStatic(Router)
|
||||
|
||||
Router.NoRoute(func(c *gin.Context) {
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(web.IndexByte)
|
||||
c.Writer.Header().Add("Accept", "text/html")
|
||||
c.Writer.Flush()
|
||||
})
|
||||
|
||||
Router.Use(i18n.GinI18nLocalize())
|
||||
Router.SetFuncMap(template.FuncMap{
|
||||
"Localize": ginI18n.GetMessage,
|
||||
@ -61,6 +59,8 @@ func Routers() *gin.Engine {
|
||||
PublicGroup.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, "ok")
|
||||
})
|
||||
PublicGroup.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
setWebStatic(PublicGroup)
|
||||
}
|
||||
PrivateGroup := Router.Group("/api/v1")
|
||||
PrivateGroup.Use(middleware.WhiteAllow())
|
||||
|
@ -32,8 +32,9 @@ func (f *FileRouter) InitFileRouter(Router *gin.RouterGroup) {
|
||||
fileRouter.POST("/rename", baseApi.ChangeFileName)
|
||||
fileRouter.POST("/wget", baseApi.WgetFile)
|
||||
fileRouter.POST("/move", baseApi.MoveFile)
|
||||
fileRouter.POST("/download", baseApi.Download)
|
||||
fileRouter.GET("/download", baseApi.Download)
|
||||
fileRouter.POST("/download/bypath", baseApi.DownloadFile)
|
||||
fileRouter.POST("/chunkdownload", baseApi.DownloadChunkFiles)
|
||||
fileRouter.POST("/size", baseApi.Size)
|
||||
fileRouter.GET("/ws", baseApi.Ws)
|
||||
fileRouter.GET("/keys", baseApi.Keys)
|
||||
|
@ -125,6 +125,11 @@ export namespace File {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FileChunkDownload {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DirSizeReq {
|
||||
path: string;
|
||||
}
|
||||
|
@ -67,9 +67,6 @@
|
||||
<el-button plain @click="openCompress(selects)" :disabled="selects.length === 0">
|
||||
{{ $t('file.compress') }}
|
||||
</el-button>
|
||||
<el-button plain @click="openDownload" :disabled="selects.length === 0">
|
||||
{{ $t('file.download') }}
|
||||
</el-button>
|
||||
<el-button plain @click="batchDelFiles" :disabled="selects.length === 0">
|
||||
{{ $t('commons.button.delete') }}
|
||||
</el-button>
|
||||
@ -156,7 +153,7 @@
|
||||
show-overflow-tooltip
|
||||
></el-table-column>
|
||||
<fu-table-operations
|
||||
:ellipsis="2"
|
||||
:ellipsis="3"
|
||||
:buttons="buttons"
|
||||
:label="$t('commons.table.operate')"
|
||||
min-width="200"
|
||||
@ -185,7 +182,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from '@vue/runtime-core';
|
||||
import { GetFilesList, DeleteFile, GetFileContent, ComputeDirSize, DownloadFile } from '@/api/modules/files';
|
||||
import { GetFilesList, DeleteFile, GetFileContent, ComputeDirSize } from '@/api/modules/files';
|
||||
import { computeSize, dateFormat, getIcon, getRandomStr } from '@/utils/util';
|
||||
import { File } from '@/api/interface/file';
|
||||
import { useDeleteData } from '@/hooks/use-delete-data';
|
||||
@ -210,6 +207,8 @@ import { MsgSuccess, MsgWarning } from '@/utils/message';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { useSearchable } from './hooks/searchable';
|
||||
import { ResultData } from '@/api/interface';
|
||||
// import streamSaver from 'streamsaver';
|
||||
// import axios from 'axios';
|
||||
|
||||
interface FilePaths {
|
||||
url: string;
|
||||
@ -245,8 +244,8 @@ const fileUpload = reactive({ path: '' });
|
||||
const fileRename = reactive({ path: '', oldName: '' });
|
||||
const fileWget = reactive({ path: '' });
|
||||
const fileMove = reactive({ oldPaths: [''], type: '', path: '' });
|
||||
const fileDownload = reactive({ paths: [''], name: '' });
|
||||
const processPage = reactive({ open: false });
|
||||
// const fileDownload = reactive({ path: '', name: '' });
|
||||
|
||||
const createRef = ref();
|
||||
const roleRef = ref();
|
||||
@ -573,32 +572,9 @@ const openPaste = () => {
|
||||
moveRef.value.acceptParams(fileMove);
|
||||
};
|
||||
|
||||
const openDownload = () => {
|
||||
const paths = [];
|
||||
for (const s of selects.value) {
|
||||
paths.push(s['path']);
|
||||
}
|
||||
fileDownload.paths = paths;
|
||||
if (selects.value.length > 1 || selects.value[0].isDir) {
|
||||
fileDownload.name = selects.value.length > 1 ? getRandomStr(6) : selects.value[0].name;
|
||||
downloadRef.value.acceptParams(fileDownload);
|
||||
} else {
|
||||
loading.value = true;
|
||||
fileDownload.name = selects.value[0].name;
|
||||
DownloadFile(fileDownload as File.FileDownload)
|
||||
.then((res) => {
|
||||
const downloadUrl = window.URL.createObjectURL(new Blob([res], { type: 'application/octet-stream' }));
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = downloadUrl;
|
||||
a.download = fileDownload.name;
|
||||
const event = new MouseEvent('click');
|
||||
a.dispatchEvent(event);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
const openDownload = (file: File.File) => {
|
||||
let url = `${import.meta.env.VITE_API_URL as string}/files/download?`;
|
||||
window.open(url + 'path=' + file.path, '_blank');
|
||||
};
|
||||
|
||||
const openDetail = (row: File.File) => {
|
||||
@ -610,6 +586,15 @@ const buttons = [
|
||||
label: i18n.global.t('file.open'),
|
||||
click: open,
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('file.download'),
|
||||
click: (row: File.File) => {
|
||||
openDownload(row);
|
||||
},
|
||||
disabled: (row: File.File) => {
|
||||
return row.isDir;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('file.deCompress'),
|
||||
click: openDeCompress,
|
||||
|
Loading…
x
Reference in New Issue
Block a user