mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-10-26 00:36:12 +08:00
feat: 优化大文件下载 (#1183)
Refs https://github.com/1Panel-dev/1Panel/issues/1165
This commit is contained in:
parent
b5093e4d93
commit
800f9e2d38
9 changed files with 129 additions and 48 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -454,17 +455,98 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
|
||||||
// @Router /files/download [post]
|
// @Router /files/download [post]
|
||||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
|
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFuntions":[],"formatZH":"下载文件 [name]","formatEN":"Download file [name]"}
|
||||||
func (b *BaseApi) Download(c *gin.Context) {
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||||
return
|
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
|
// @Tags File
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ type FileDownload struct {
|
||||||
Compress bool `json:"compress" validate:"required"`
|
Compress bool `json:"compress" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileChunkDownload struct {
|
||||||
|
Path string `json:"path" validate:"required"`
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type DirSizeReq struct {
|
type DirSizeReq struct {
|
||||||
Path string `json:"path" validate:"required"`
|
Path string `json:"path" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ var (
|
||||||
ErrLinkPathNotFound = "ErrLinkPathNotFound"
|
ErrLinkPathNotFound = "ErrLinkPathNotFound"
|
||||||
ErrFileIsExit = "ErrFileIsExit"
|
ErrFileIsExit = "ErrFileIsExit"
|
||||||
ErrFileUpload = "ErrFileUpload"
|
ErrFileUpload = "ErrFileUpload"
|
||||||
|
ErrFileDownloadDir = "ErrFileDownloadDir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mysql
|
// mysql
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ ErrMovePathFailed: "The target path cannot contain the original path!"
|
||||||
ErrLinkPathNotFound: "Target path does not exist!"
|
ErrLinkPathNotFound: "Target path does not exist!"
|
||||||
ErrFileIsExit: "File already exists!"
|
ErrFileIsExit: "File already exists!"
|
||||||
ErrFileUpload: "Failed to upload file {{.name}} {{.detail}}"
|
ErrFileUpload: "Failed to upload file {{.name}} {{.detail}}"
|
||||||
|
ErrFileDownloadDir: "Download folder not supported"
|
||||||
|
|
||||||
#website
|
#website
|
||||||
ErrDomainIsExist: "Domain is already exist"
|
ErrDomainIsExist: "Domain is already exist"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ ErrMovePathFailed: "目标路径不能包含原路径!"
|
||||||
ErrLinkPathNotFound: "目标路径不存在!"
|
ErrLinkPathNotFound: "目标路径不存在!"
|
||||||
ErrFileIsExit: "文件已存在!"
|
ErrFileIsExit: "文件已存在!"
|
||||||
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail}}"
|
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail}}"
|
||||||
|
ErrFileDownloadDir: "不支持下载文件夹"
|
||||||
|
|
||||||
#website
|
#website
|
||||||
ErrDomainIsExist: "域名已存在"
|
ErrDomainIsExist: "域名已存在"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/gin-contrib/gzip"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/backend/global"
|
"github.com/1Panel-dev/1Panel/backend/global"
|
||||||
"github.com/1Panel-dev/1Panel/backend/i18n"
|
"github.com/1Panel-dev/1Panel/backend/i18n"
|
||||||
"github.com/1Panel-dev/1Panel/backend/middleware"
|
"github.com/1Panel-dev/1Panel/backend/middleware"
|
||||||
|
|
@ -18,23 +17,16 @@ import (
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setWebStatic(rootRouter *gin.Engine) {
|
func setWebStatic(rootRouter *gin.RouterGroup) {
|
||||||
rootRouter.StaticFS("/fav", http.FS(web.Favicon))
|
rootRouter.StaticFS("/fav", http.FS(web.Favicon))
|
||||||
rootRouter.GET("/assets/*filepath", func(c *gin.Context) {
|
rootRouter.GET("/assets/*filepath", func(c *gin.Context) {
|
||||||
staticServer := http.FileServer(http.FS(web.Assets))
|
staticServer := http.FileServer(http.FS(web.Assets))
|
||||||
staticServer.ServeHTTP(c.Writer, c.Request)
|
staticServer.ServeHTTP(c.Writer, c.Request)
|
||||||
})
|
})
|
||||||
|
|
||||||
rootRouter.GET("/", func(c *gin.Context) {
|
rootRouter.GET("/", func(c *gin.Context) {
|
||||||
staticServer := http.FileServer(http.FS(web.IndexHtml))
|
staticServer := http.FileServer(http.FS(web.IndexHtml))
|
||||||
staticServer.ServeHTTP(c.Writer, c.Request)
|
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 {
|
func Routers() *gin.Engine {
|
||||||
|
|
@ -45,8 +37,14 @@ func Routers() *gin.Engine {
|
||||||
if global.CONF.System.IsDemo {
|
if global.CONF.System.IsDemo {
|
||||||
Router.Use(middleware.DemoHandle())
|
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.Use(i18n.GinI18nLocalize())
|
||||||
Router.SetFuncMap(template.FuncMap{
|
Router.SetFuncMap(template.FuncMap{
|
||||||
"Localize": ginI18n.GetMessage,
|
"Localize": ginI18n.GetMessage,
|
||||||
|
|
@ -61,6 +59,8 @@ func Routers() *gin.Engine {
|
||||||
PublicGroup.GET("/health", func(c *gin.Context) {
|
PublicGroup.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, "ok")
|
c.JSON(200, "ok")
|
||||||
})
|
})
|
||||||
|
PublicGroup.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
|
setWebStatic(PublicGroup)
|
||||||
}
|
}
|
||||||
PrivateGroup := Router.Group("/api/v1")
|
PrivateGroup := Router.Group("/api/v1")
|
||||||
PrivateGroup.Use(middleware.WhiteAllow())
|
PrivateGroup.Use(middleware.WhiteAllow())
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,9 @@ func (f *FileRouter) InitFileRouter(Router *gin.RouterGroup) {
|
||||||
fileRouter.POST("/rename", baseApi.ChangeFileName)
|
fileRouter.POST("/rename", baseApi.ChangeFileName)
|
||||||
fileRouter.POST("/wget", baseApi.WgetFile)
|
fileRouter.POST("/wget", baseApi.WgetFile)
|
||||||
fileRouter.POST("/move", baseApi.MoveFile)
|
fileRouter.POST("/move", baseApi.MoveFile)
|
||||||
fileRouter.POST("/download", baseApi.Download)
|
fileRouter.GET("/download", baseApi.Download)
|
||||||
fileRouter.POST("/download/bypath", baseApi.DownloadFile)
|
fileRouter.POST("/download/bypath", baseApi.DownloadFile)
|
||||||
|
fileRouter.POST("/chunkdownload", baseApi.DownloadChunkFiles)
|
||||||
fileRouter.POST("/size", baseApi.Size)
|
fileRouter.POST("/size", baseApi.Size)
|
||||||
fileRouter.GET("/ws", baseApi.Ws)
|
fileRouter.GET("/ws", baseApi.Ws)
|
||||||
fileRouter.GET("/keys", baseApi.Keys)
|
fileRouter.GET("/keys", baseApi.Keys)
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,11 @@ export namespace File {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileChunkDownload {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirSizeReq {
|
export interface DirSizeReq {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,6 @@
|
||||||
<el-button plain @click="openCompress(selects)" :disabled="selects.length === 0">
|
<el-button plain @click="openCompress(selects)" :disabled="selects.length === 0">
|
||||||
{{ $t('file.compress') }}
|
{{ $t('file.compress') }}
|
||||||
</el-button>
|
</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">
|
<el-button plain @click="batchDelFiles" :disabled="selects.length === 0">
|
||||||
{{ $t('commons.button.delete') }}
|
{{ $t('commons.button.delete') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
@ -156,7 +153,7 @@
|
||||||
show-overflow-tooltip
|
show-overflow-tooltip
|
||||||
></el-table-column>
|
></el-table-column>
|
||||||
<fu-table-operations
|
<fu-table-operations
|
||||||
:ellipsis="2"
|
:ellipsis="3"
|
||||||
:buttons="buttons"
|
:buttons="buttons"
|
||||||
:label="$t('commons.table.operate')"
|
:label="$t('commons.table.operate')"
|
||||||
min-width="200"
|
min-width="200"
|
||||||
|
|
@ -185,7 +182,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, reactive, ref } from '@vue/runtime-core';
|
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 { computeSize, dateFormat, getIcon, getRandomStr } from '@/utils/util';
|
||||||
import { File } from '@/api/interface/file';
|
import { File } from '@/api/interface/file';
|
||||||
import { useDeleteData } from '@/hooks/use-delete-data';
|
import { useDeleteData } from '@/hooks/use-delete-data';
|
||||||
|
|
@ -210,6 +207,8 @@ import { MsgSuccess, MsgWarning } from '@/utils/message';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessageBox } from 'element-plus';
|
||||||
import { useSearchable } from './hooks/searchable';
|
import { useSearchable } from './hooks/searchable';
|
||||||
import { ResultData } from '@/api/interface';
|
import { ResultData } from '@/api/interface';
|
||||||
|
// import streamSaver from 'streamsaver';
|
||||||
|
// import axios from 'axios';
|
||||||
|
|
||||||
interface FilePaths {
|
interface FilePaths {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -245,8 +244,8 @@ const fileUpload = reactive({ path: '' });
|
||||||
const fileRename = reactive({ path: '', oldName: '' });
|
const fileRename = reactive({ path: '', oldName: '' });
|
||||||
const fileWget = reactive({ path: '' });
|
const fileWget = reactive({ path: '' });
|
||||||
const fileMove = reactive({ oldPaths: [''], type: '', path: '' });
|
const fileMove = reactive({ oldPaths: [''], type: '', path: '' });
|
||||||
const fileDownload = reactive({ paths: [''], name: '' });
|
|
||||||
const processPage = reactive({ open: false });
|
const processPage = reactive({ open: false });
|
||||||
|
// const fileDownload = reactive({ path: '', name: '' });
|
||||||
|
|
||||||
const createRef = ref();
|
const createRef = ref();
|
||||||
const roleRef = ref();
|
const roleRef = ref();
|
||||||
|
|
@ -573,32 +572,9 @@ const openPaste = () => {
|
||||||
moveRef.value.acceptParams(fileMove);
|
moveRef.value.acceptParams(fileMove);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDownload = () => {
|
const openDownload = (file: File.File) => {
|
||||||
const paths = [];
|
let url = `${import.meta.env.VITE_API_URL as string}/files/download?`;
|
||||||
for (const s of selects.value) {
|
window.open(url + 'path=' + file.path, '_blank');
|
||||||
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 openDetail = (row: File.File) => {
|
const openDetail = (row: File.File) => {
|
||||||
|
|
@ -610,6 +586,15 @@ const buttons = [
|
||||||
label: i18n.global.t('file.open'),
|
label: i18n.global.t('file.open'),
|
||||||
click: 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'),
|
label: i18n.global.t('file.deCompress'),
|
||||||
click: openDeCompress,
|
click: openDeCompress,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue