1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-02-14 20:40:06 +08:00

feat: Check for duplicate files before uploading (#7849)

Refs #5893
This commit is contained in:
2025-02-11 18:11:48 +08:00 committed by GitHub
parent ab932ae154
commit 8bb225d272
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 300 additions and 73 deletions

View File

@ -417,6 +417,23 @@ func (b *BaseApi) CheckFile(c *gin.Context) {
helper.SuccessWithData(c, true)
}
// @Tags File
// @Summary Batch check file exist
// @Accept json
// @Param request body request.FilePathsCheck true "request"
// @Success 200 {array} response.ExistFileInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/batch/check [post]
func (b *BaseApi) BatchCheckFiles(c *gin.Context) {
var req request.FilePathsCheck
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
fileList := fileService.BatchCheckFiles(req)
helper.SuccessWithData(c, fileList)
}
// @Tags File
// @Summary Change file name
// @Accept json

View File

@ -79,6 +79,10 @@ type FilePathCheck struct {
Path string `json:"path" validate:"required"`
}
type FilePathsCheck struct {
Paths []string `json:"paths" validate:"required"`
}
type FileWget struct {
Url string `json:"url" validate:"required"`
Path string `json:"path" validate:"required"`

View File

@ -2,6 +2,7 @@ package response
import (
"github.com/1Panel-dev/1Panel/backend/utils/files"
"time"
)
type FileInfo struct {
@ -46,3 +47,10 @@ type FileLineContent struct {
type FileExist struct {
Exist bool `json:"exist"`
}
type ExistFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size float64 `json:"size"`
ModTime time.Time `json:"modTime"`
}

View File

@ -50,6 +50,7 @@ type IFileService interface {
ChangeMode(op request.FileCreate) error
BatchChangeModeAndOwner(op request.FileRoleReq) error
ReadLogByLine(req request.FileReadByLineReq) (*response.FileLineContent, error)
BatchCheckFiles(req request.FilePathsCheck) []response.ExistFileInfo
}
var filteredPaths = []string{
@ -501,3 +502,18 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
}
return res, nil
}
func (f *FileService) BatchCheckFiles(req request.FilePathsCheck) []response.ExistFileInfo {
fileList := make([]response.ExistFileInfo, 0, len(req.Paths))
for _, filePath := range req.Paths {
if info, err := os.Stat(filePath); err == nil {
fileList = append(fileList, response.ExistFileInfo{
Size: float64(info.Size()),
Name: info.Name(),
Path: filePath,
ModTime: info.ModTime(),
})
}
}
return fileList
}

View File

@ -27,6 +27,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.POST("/content", baseApi.GetContent)
fileRouter.POST("/save", baseApi.SaveContent)
fileRouter.POST("/check", baseApi.CheckFile)
fileRouter.POST("/batch/check", baseApi.BatchCheckFiles)
fileRouter.POST("/upload", baseApi.UploadFiles)
fileRouter.POST("/chunkupload", baseApi.UploadChunkFiles)
fileRouter.POST("/rename", baseApi.ChangeFileName)

View File

@ -152,6 +152,14 @@ export namespace File {
path: string;
}
export interface ExistFileInfo {
name: string;
path: string;
size: number;
uploadSize: number;
modTime: string;
}
export interface RecycleBin {
sourcePath: string;
name: string;

View File

@ -53,6 +53,10 @@ export const CheckFile = (path: string) => {
return http.post<boolean>('files/check', { path: path });
};
export const BatchCheckFiles = (paths: string[]) => {
return http.post<File.ExistFileInfo[]>('files/batch/check', { paths: paths }, TimeoutEnum.T_5M);
};
export const UploadFileData = (params: FormData, config: AxiosRequestConfig) => {
return http.upload<File.File>('files/upload', params, config);
};

View File

@ -0,0 +1,78 @@
<template>
<div>
<el-dialog
v-model="dialogVisible"
:title="$t('file.existFileTitle')"
width="35%"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-alert :show-icon="true" type="warning" :closable="false">
<div class="whitespace-break-spaces">
<span>{{ $t('file.existFileHelper') }}</span>
</div>
</el-alert>
<div>
<el-table :data="existFiles" max-height="350">
<el-table-column type="index" :label="$t('commons.table.serialNumber')" width="55" />
<el-table-column prop="path" :label="$t('commons.table.name')" :min-width="200" />
<el-table-column :label="$t('file.existFileSize')" width="230">
<template #default="{ row }">
{{ getFileSize(row.uploadSize) }} -> {{ getFileSize(row.size) }}
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleSkip">{{ $t('commons.button.skip') }}</el-button>
<el-button type="primary" @click="handleOverwrite()">
{{ $t('commons.button.cover') }}
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { computeSize } from '@/utils/util';
const dialogVisible = ref();
const existFiles = ref<DialogProps[]>([]);
interface DialogProps {
name: string;
path: string;
size: number;
uploadSize: number;
modTime: string;
}
let onConfirmCallback = null;
const getFileSize = (size: number) => {
return computeSize(size);
};
const handleSkip = () => {
dialogVisible.value = false;
if (onConfirmCallback) {
onConfirmCallback(
'skip',
existFiles.value.map((file) => file.path),
);
}
};
const handleOverwrite = () => {
dialogVisible.value = false;
if (onConfirmCallback) {
onConfirmCallback('overwrite');
}
};
const acceptParams = async ({ paths, onConfirm }): Promise<void> => {
existFiles.value = paths;
onConfirmCallback = onConfirm;
dialogVisible.value = true;
};
defineExpose({ acceptParams });
</script>

View File

@ -74,6 +74,8 @@ const message = {
createNewFile: 'Create new file',
helpDoc: 'Help Document',
unbind: 'Unbind',
cover: 'cover',
skip: 'skip',
},
search: {
timeStart: 'Time start',
@ -110,6 +112,7 @@ const message = {
refreshRate: 'Refresh rate',
refreshRateUnit: 'No refresh | {n} second/time | {n} seconds/time',
selectColumn: 'Select column',
serialNumber: 'Serial number',
},
loadingText: {
Upgrading: 'System upgrade, please wait...',
@ -1320,6 +1323,9 @@ const message = {
minimap: 'Code mini map',
fileCanNotRead: 'File can not read',
panelInstallDir: `1Panel installation directory can't be deleted`,
existFileTitle: 'Same name file prompt',
existFileHelper: 'The uploaded file contains a file with the same name, do you want to overwrite it?',
existFileSize: 'File size (new -> old)',
},
ssh: {
setting: 'Setting',

View File

@ -73,6 +73,8 @@ const message = {
createNewFile: '新しいファイルを作成します',
helpDoc: '文書をヘルプします',
unbind: 'バインド',
cover: '上書き',
skip: 'スキップ',
},
search: {
timeStart: '時間開始',
@ -109,6 +111,7 @@ const message = {
refreshRate: 'リフレッシュレート',
refreshRateUnit: '更新なし|{n}/時間 |{n}/時間',
selectColumn: '列を選択します',
serialNumber: 'シリアル番号',
},
loadingText: {
Upgrading: 'システムのアップグレード待ってください...',
@ -1298,6 +1301,9 @@ const message = {
minimap: 'コードミニマップ',
fileCanNotRead: 'ファイルは読み取れません',
panelInstallDir: `1Panelインストールディレクトリは削除できません`,
existFileTitle: '同名ファイルの警告',
existFileHelper: 'アップロードしたファイルに同じ名前のファイルが含まれています上書きしますか',
existFileSize: 'ファイルサイズ新しい -> 古い',
},
ssh: {
setting: '設定',

View File

@ -74,6 +74,8 @@ const message = {
createNewFile: ' 파일 생성',
helpDoc: '도움말 문서',
unbind: '연결 해제',
cover: '덮어쓰기',
skip: '건너뛰기',
},
search: {
timeStart: '시작 시간',
@ -110,6 +112,7 @@ const message = {
refreshRate: '새로 고침 속도',
refreshRateUnit: '새로 고침 | {n} / | {n} /',
selectColumn: ' 선택',
serialNumber: '일련 번호',
},
loadingText: {
Upgrading: '시스템 업그레이드 중입니다. 잠시만 기다려 주십시오...',
@ -1285,6 +1288,9 @@ const message = {
minimap: '코드 미니맵',
fileCanNotRead: '파일을 읽을 없습니다.',
panelInstallDir: `1Panel 설치 디렉터리는 삭제할 수 없습니다.`,
existFileTitle: '동일한 이름의 파일 경고',
existFileHelper: '업로드한 파일에 동일한 이름의 파일이 포함되어 있습니다. 덮어쓰시겠습니까?',
existFileSize: '파일 크기 (새로운 -> 오래된)',
},
ssh: {
setting: '설정',

View File

@ -74,6 +74,8 @@ const message = {
createNewFile: 'Cipta fail baru',
helpDoc: 'Dokumen Bantuan',
unbind: 'Nyahkaitkan',
cover: 'Tindih',
skip: 'Langkau',
},
search: {
timeStart: 'Masa mula',
@ -110,6 +112,7 @@ const message = {
refreshRate: 'Kadar penyegaran',
refreshRateUnit: 'Tiada penyegaran | {n} saat/masa | {n} saat/masa',
selectColumn: 'Pilih lajur',
serialNumber: 'Nombor siri',
},
loadingText: {
Upgrading: 'Peningkatan sistem, sila tunggu...',
@ -1340,6 +1343,9 @@ const message = {
minimap: 'Peta mini kod',
fileCanNotRead: 'Fail tidak dapat dibaca',
panelInstallDir: 'Direktori pemasangan 1Panel tidak boleh dipadamkan',
existFileTitle: 'Amaran fail dengan nama yang sama',
existFileHelper: 'Fail yang dimuat naik mengandungi fail dengan nama yang sama. Adakah anda mahu menimpanya?',
existFileSize: 'Saiz fail (baru -> lama)',
},
ssh: {
setting: 'tetapan',

View File

@ -74,6 +74,8 @@ const message = {
createNewFile: 'Criar novo arquivo',
helpDoc: 'Documento de ajuda',
unbind: 'Desvincular',
cover: 'Substituir',
skip: 'Pular',
},
search: {
timeStart: 'Hora inicial',
@ -110,6 +112,7 @@ const message = {
refreshRate: 'Taxa de atualização',
refreshRateUnit: 'Sem atualização | {n} segundo/atualização | {n} segundos/atualização',
selectColumn: 'Selecionar coluna',
serialNumber: 'Número de série',
},
loadingText: {
Upgrading: 'Atualizando o sistema, por favor, aguarde...',
@ -1326,6 +1329,9 @@ const message = {
minimap: 'Mini mapa de código',
fileCanNotRead: 'O arquivo não pode ser lido',
panelInstallDir: 'O diretório de instalação do 1Panel não pode ser excluído',
existFileTitle: 'Aviso de arquivo com o mesmo nome',
existFileHelper: 'O arquivo enviado contém um arquivo com o mesmo nome. Deseja substituí-lo?',
existFileSize: 'Tamanho do arquivo (novo -> antigo)',
},
ssh: {
setting: 'configuração',

View File

@ -74,6 +74,8 @@ const message = {
createNewFile: 'Создать новый файл',
helpDoc: 'Справка',
unbind: 'Отвязать',
cover: 'Заменить',
skip: 'Пропустить',
},
search: {
timeStart: 'Время начала',
@ -110,6 +112,8 @@ const message = {
refreshRate: 'Частота обновления',
refreshRateUnit: 'Без обновления | {n} секунда/раз | {n} секунд/раз',
selectColumn: 'Выбрать столбец',
cover: 'Заменить',
skip: 'Пропустить',
},
loadingText: {
Upgrading: 'Обновление системы, пожалуйста, подождите...',
@ -1328,6 +1332,9 @@ const message = {
minimap: 'Мини-карта кода',
fileCanNotRead: 'Файл не может быть прочитан',
panelInstallDir: 'Директорию установки 1Panel нельзя удалить',
existFileTitle: 'Предупреждение о файле с тем же именем',
existFileHelper: 'Загруженный файл содержит файл с таким же именем. Заменить его?',
existFileSize: 'Размер файла (новый -> старый)',
},
ssh: {
setting: 'настройка',

View File

@ -73,6 +73,8 @@ const message = {
createNewFile: '新增檔案',
helpDoc: '說明文件',
unbind: '解綁',
cover: '覆蓋',
skip: '跳過',
},
search: {
timeStart: '開始時間',
@ -110,6 +112,7 @@ const message = {
noRefresh: '不更新',
refreshRateUnit: '不更新 | {0} / | {0} /',
selectColumn: '選擇列',
serialNumber: '序號',
},
loadingText: {
Upgrading: '系統升級中請稍候...',
@ -1257,6 +1260,9 @@ const message = {
minimap: '縮圖',
fileCanNotRead: '此檔案不支援預覽',
panelInstallDir: '1Panel 安裝目錄不能刪除',
existFileTitle: '同名檔案提示',
existFileHelper: '上傳的檔案存在同名檔案是否覆蓋',
existFileSize: '文件大小->',
},
ssh: {
setting: '設定',

View File

@ -73,6 +73,8 @@ const message = {
createNewFile: '新建文件',
helpDoc: '帮助文档',
unbind: '解绑',
cover: '覆盖',
skip: '跳过',
},
search: {
timeStart: '开始时间',
@ -109,6 +111,7 @@ const message = {
refreshRate: '刷新频率',
refreshRateUnit: '不刷新 | {n} / | {n} /',
selectColumn: '选择列',
serialNumber: '序号',
},
loadingText: {
Upgrading: '系统升级中请稍候...',
@ -1258,6 +1261,9 @@ const message = {
minimap: '缩略图',
fileCanNotRead: '此文件不支持预览',
panelInstallDir: '1Panel 安装目录不能删除',
existFileTitle: '同名文件提示',
existFileHelper: '上传的文件存在同名文件是否覆盖',
existFileSize: '文件大小 ( -> )',
},
ssh: {
setting: '配置',

View File

@ -85,18 +85,20 @@
</el-button>
</span>
</template>
<ExistFileDialog ref="dialogExistFileRef" />
</el-drawer>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue';
import { UploadFile, UploadFiles, UploadInstance, UploadProps, UploadRawFile } from 'element-plus';
import { ChunkUploadFileData, UploadFileData } from '@/api/modules/files';
import { BatchCheckFiles, ChunkUploadFileData, UploadFileData } from '@/api/modules/files';
import i18n from '@/lang';
import DrawerHeader from '@/components/drawer-header/index.vue';
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
import { Close, Document, UploadFilled } from '@element-plus/icons-vue';
import { TimeoutEnum } from '@/enums/http-enum';
import ExistFileDialog from '@/components/exist-file/index.vue';
interface UploadFileProps {
path: string;
@ -108,6 +110,7 @@ let uploadPercent = ref(0);
const open = ref(false);
const path = ref();
let uploadHelper = ref('');
const dialogExistFileRef = ref();
const em = defineEmits(['close']);
const handleClose = () => {
@ -122,6 +125,8 @@ const uploaderFiles = ref<UploadFiles>([]);
const hoverIndex = ref<number | null>(null);
const tmpFiles = ref<UploadFiles>([]);
const breakFlag = ref(false);
const CHUNK_SIZE = 1024 * 1024 * 5;
const MAX_SINGLE_FILE_SIZE = 1024 * 1024 * 10;
const upload = (command: string) => {
state.uploadEle.webkitdirectory = command == 'dir';
@ -257,87 +262,125 @@ const handleSuccess: UploadProps['onSuccess'] = (res, file) => {
};
const submit = async () => {
loading.value = true;
let success = 0;
const files = uploaderFiles.value.slice();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileSize = file.size;
uploadHelper.value = i18n.global.t('file.fileUploadStart', [file.name]);
if (fileSize <= 1024 * 1024 * 10) {
const formData = new FormData();
formData.append('file', file.raw);
if (file.raw.webkitRelativePath != '') {
formData.append('path', path.value + '/' + getPathWithoutFilename(file.raw.webkitRelativePath));
} else {
formData.append('path', path.value + '/' + getPathWithoutFilename(file.name));
const fileNamesWithPath = Array.from(
new Set(files.map((file) => `${path.value}/${file.raw.webkitRelativePath || file.name}`)),
);
const existFiles = await BatchCheckFiles(fileNamesWithPath);
if (existFiles.data.length > 0) {
const fileSizeMap = new Map(
files.map((file) => [`${path.value}/${file.raw.webkitRelativePath || file.name}`, file.size]),
);
existFiles.data.forEach((file) => {
if (fileSizeMap.has(file.path)) {
file.uploadSize = fileSizeMap.get(file.path);
}
formData.append('overwrite', 'True');
uploadPercent.value = 0;
await UploadFileData(formData, {
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
uploadPercent.value = progress;
},
timeout: 40000,
});
success++;
uploaderFiles.value[i].status = 'success';
} else {
const CHUNK_SIZE = 1024 * 1024 * 5;
const chunkCount = Math.ceil(fileSize / CHUNK_SIZE);
let uploadedChunkCount = 0;
for (let c = 0; c < chunkCount; c++) {
const start = c * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileSize);
const chunk = file.raw.slice(start, end);
const formData = new FormData();
});
dialogExistFileRef.value.acceptParams({
paths: existFiles.data,
onConfirm: handleFileUpload,
});
} else {
await uploadFile(files);
}
};
formData.append('filename', getFilenameFromPath(file.name));
if (file.raw.webkitRelativePath != '') {
formData.append('path', path.value + '/' + getPathWithoutFilename(file.raw.webkitRelativePath));
} else {
formData.append('path', path.value + '/' + getPathWithoutFilename(file.name));
}
formData.append('chunk', chunk);
formData.append('chunkIndex', c.toString());
formData.append('chunkCount', chunkCount.toString());
const handleFileUpload = (action: 'skip' | 'overwrite', skippedPaths: string[] = []) => {
const files = uploaderFiles.value.slice();
if (action === 'skip') {
const filteredFiles = files.filter(
(file) => !skippedPaths.includes(`${path.value}/${file.raw.webkitRelativePath || file.name}`),
);
uploaderFiles.value = filteredFiles;
uploadFile(filteredFiles);
} else if (action === 'overwrite') {
uploadFile(files);
}
};
try {
await ChunkUploadFileData(formData, {
onUploadProgress: (progressEvent) => {
const progress = Math.round(
((uploadedChunkCount + progressEvent.loaded / progressEvent.total) * 100) / chunkCount,
);
uploadPercent.value = progress;
},
timeout: TimeoutEnum.T_60S,
});
uploadedChunkCount++;
} catch (error) {
uploaderFiles.value[i].status = 'fail';
break;
}
if (uploadedChunkCount == chunkCount) {
success++;
uploaderFiles.value[i].status = 'success';
break;
}
const uploadFile = async (files: any[]) => {
if (files.length == 0) {
clearFiles();
} else {
loading.value = true;
let successCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
uploadHelper.value = i18n.global.t('file.fileUploadStart', [file.name]);
let isSuccess =
file.size <= MAX_SINGLE_FILE_SIZE ? await uploadSingleFile(file) : await uploadLargeFile(file);
if (isSuccess) {
successCount++;
uploaderFiles.value[i].status = 'success';
} else {
uploaderFiles.value[i].status = 'fail';
}
}
if (i == files.length - 1) {
loading.value = false;
uploadHelper.value = '';
if (success == files.length) {
clearFiles();
MsgSuccess(i18n.global.t('file.uploadSuccess'));
}
loading.value = false;
uploadHelper.value = '';
if (successCount === files.length) {
clearFiles();
MsgSuccess(i18n.global.t('file.uploadSuccess'));
}
}
};
const uploadSingleFile = async (file: { raw: string | Blob }) => {
const formData = new FormData();
formData.append('file', file.raw);
formData.append('path', getUploadPath(file));
formData.append('overwrite', 'True');
uploadPercent.value = 0;
await UploadFileData(formData, {
onUploadProgress: (progressEvent) => {
uploadPercent.value = Math.round((progressEvent.loaded / progressEvent.total) * 100);
},
timeout: 40000,
});
return true;
};
const uploadLargeFile = async (file: { size: any; raw: string | Blob; name: string }) => {
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / CHUNK_SIZE);
let uploadedChunkCount = 0;
for (let c = 0; c < chunkCount; c++) {
const start = c * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileSize);
const chunk = file.raw.slice(start, end);
const formData = new FormData();
formData.append('filename', getFilenameFromPath(file.name));
formData.append('path', getUploadPath(file));
formData.append('chunk', chunk);
formData.append('chunkIndex', c.toString());
formData.append('chunkCount', chunkCount.toString());
try {
await ChunkUploadFileData(formData, {
onUploadProgress: (progressEvent) => {
uploadPercent.value = Math.round(
((uploadedChunkCount + progressEvent.loaded / progressEvent.total) * 100) / chunkCount,
);
},
timeout: TimeoutEnum.T_60S,
});
uploadedChunkCount++;
} catch (error) {
return false;
}
}
return uploadedChunkCount === chunkCount;
};
const getUploadPath = (file) => {
return `${path.value}/${getPathWithoutFilename(file.raw.webkitRelativePath || file.name)}`;
};
const getPathWithoutFilename = (path: string) => {
return path ? path.split('/').slice(0, -1).join('/') : path;
};
@ -353,8 +396,7 @@ const acceptParams = (props: UploadFileProps) => {
uploadHelper.value = '';
nextTick(() => {
const uploadEle = document.querySelector('.el-upload__input');
state.uploadEle = uploadEle;
state.uploadEle = document.querySelector('.el-upload__input');
});
};