feat: Check for duplicate files before uploading/copying/moving (#8177)

This commit is contained in:
2025-03-18 17:16:15 +08:00 committed by GitHub
parent e7124b26df
commit 47d5e919a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 602 additions and 248 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/agent/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 int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}

View file

@ -54,6 +54,7 @@ type IFileService interface {
ReadLogByLine(req request.FileReadByLineReq) (*response.FileLineContent, error)
GetPathByType(pathType string) string
BatchCheckFiles(req request.FilePathsCheck) []response.ExistFileInfo
}
var filteredPaths = []string{
@ -518,3 +519,18 @@ func (f *FileService) GetPathByType(pathType string) string {
}
return ""
}
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: info.Size(),
Name: info.Name(),
Path: filePath,
ModTime: info.ModTime(),
})
}
}
return fileList
}

View file

@ -25,6 +25,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

@ -29,7 +29,7 @@ type Login struct {
Captcha string `json:"captcha"`
CaptchaID string `json:"captchaID"`
AuthMethod string `json:"authMethod" validate:"required,oneof=jwt session"`
Language string `json:"language" validate:"required,oneof=zh en tw ja ru ms 'pt-BR'"`
Language string `json:"language" validate:"required,oneof=zh en 'zh-Hant' ko ja ru ms 'pt-BR'"`
}
type MFALogin struct {

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

@ -57,6 +57,10 @@ export const uploadFileData = (params: FormData, config: AxiosRequestConfig) =>
return http.upload<File.File>('files/upload', params, config);
};
export const batchCheckFiles = (paths: string[]) => {
return http.post<File.ExistFileInfo[]>('files/batch/check', { paths: paths }, TimeoutEnum.T_5M);
};
export const chunkUploadFileData = (params: FormData, config: AxiosRequestConfig) => {
return http.upload<File.File>('files/chunkupload', params, config);
};

View file

@ -87,6 +87,8 @@ const size = computed(() => {
return '100%';
case '60%':
return '60%';
case props.size:
return props.size;
default:
return '50%';
}

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

@ -73,6 +73,8 @@ const message = {
helpDoc: 'Help Document',
bind: 'Bind',
unbind: 'Unbind',
cover: 'cover',
skip: 'skip',
fix: 'Fix',
down: 'Stop',
up: 'Start',
@ -126,6 +128,7 @@ const message = {
refreshRateUnit: '{0} Seconds/Time',
selectColumn: 'Select column',
local: 'local',
serialNumber: 'Serial number',
},
loadingText: {
Upgrading: 'System upgrade, please wait...',
@ -1411,6 +1414,10 @@ const message = {
fileCanNotRead: 'File can not read',
panelInstallDir: '1Panel installation directory cannot be deleted',
wgetTask: 'Download Task',
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)',
existFileDirHelper: 'The selected file/folder has a duplicate name. Please proceed with caution!',
},
ssh: {
autoStart: 'Auto Start',

View file

@ -52,13 +52,13 @@ const message = {
get: '得る',
upgrade: 'アップグレード',
update: '編集',
ignore: 'アップグレードを無視します',
ignore: '更新を無視する',
copy: 'コピー',
random: 'ランダム',
install: 'インストール',
uninstall: 'アンインストール',
fullscreen: 'フルスクリーンを入力します',
quitFullscreen: 'フルスクリーンを終了します',
fullscreen: 'フルスクリーン',
quitFullscreen: 'フルスクリーンを終了',
showAll: 'すべてを表示します',
hideSome: 'いくつかを隠します',
agree: '同意する',
@ -70,6 +70,8 @@ const message = {
createNewFile: '新しいファイルを作成します',
helpDoc: '文書をヘルプします',
unbind: 'バインド',
cover: 'に覆いを',
skip: 'スキップ',
fix: '修正',
down: '停止',
up: '起動',
@ -115,9 +117,10 @@ const message = {
protocol: 'プロトコル',
tableSetting: 'テーブル設定',
refreshRate: 'リフレッシュレート',
refreshRateUnit: '更新なし|{n}/ |{n}/',
refreshRateUnit: '更新なし|{n}/ |{n}/',
selectColumn: '列を選択します',
local: 'ローカル',
serialNumber: 'シリアル番号',
},
loadingText: {
Upgrading: 'システムのアップグレード待ってください...',
@ -1350,6 +1353,10 @@ const message = {
fileCanNotRead: 'ファイルは読み取れません',
panelInstallDir: `1Panelインストールディレクトリは削除できません`,
wgetTask: 'ダウンロードタスク',
existFileTitle: '同名ファイルの警告',
existFileHelper: 'アップロードしたファイルに同じ名前のファイルが含まれています上書きしますか',
existFileSize: 'ファイルサイズ新しい -> 古い',
existFileDirHelper: '選択したファイル/フォルダーには同じ名前のものが既に存在します慎重に操作してください',
},
ssh: {
setting: '設定',

View file

@ -70,6 +70,8 @@ const message = {
createNewFile: ' 파일 생성',
helpDoc: '도움말 문서',
unbind: '연결 해제',
cover: '덮어쓰기',
skip: '건너뛰기',
fix: '수정',
down: '중지',
up: '시작',
@ -118,6 +120,7 @@ const message = {
refreshRateUnit: '새로 고침 | {n} / | {n} /',
selectColumn: ' 선택',
local: '로컬',
serialNumber: '일련 번호',
},
loadingText: {
Upgrading: '시스템 업그레이드 중입니다. 잠시만 기다려 주십시오...',
@ -1337,6 +1340,10 @@ const message = {
fileCanNotRead: '파일을 읽을 없습니다.',
panelInstallDir: `1Panel 설치 디렉터리는 삭제할 수 없습니다.`,
wgetTask: '다운로드 작업',
existFileTitle: '동일한 이름의 파일 경고',
existFileHelper: '업로드한 파일에 동일한 이름의 파일이 포함되어 있습니다. 덮어쓰시겠습니까?',
existFileSize: '파일 크기 (새로운 -> 오래된)',
existFileDirHelper: '선택한 파일/폴더에 동일한 이름이 이미 존재합니다. 신중하게 작업하세요!',
},
ssh: {
setting: '설정',

View file

@ -70,6 +70,8 @@ const message = {
createNewFile: 'Cipta fail baru',
helpDoc: 'Dokumen Bantuan',
unbind: 'Nyahkaitkan',
cover: 'Tindih',
skip: 'Langkau',
fix: 'Betulkan',
down: 'Hentikan',
up: 'Mulakan',
@ -118,6 +120,7 @@ const message = {
refreshRateUnit: 'Tiada penyegaran | {n} saat/masa | {n} saat/masa',
selectColumn: 'Pilih lajur',
local: 'Tempatan',
serialNumber: 'Nombor siri',
},
loadingText: {
Upgrading: 'Peningkatan sistem, sila tunggu...',
@ -1394,6 +1397,10 @@ const message = {
fileCanNotRead: 'Fail tidak dapat dibaca',
panelInstallDir: 'Direktori pemasangan 1Panel tidak boleh dipadamkan',
wgetTask: 'Tugas Muat Turun',
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)',
existFileDirHelper: 'Fail/folder yang dipilih mempunyai nama yang sama. Sila berhati-hati!',
},
ssh: {
setting: 'tetapan',

View file

@ -70,6 +70,8 @@ const message = {
createNewFile: 'Criar novo arquivo',
helpDoc: 'Documento de ajuda',
unbind: 'Desvincular',
cover: 'Substituir',
skip: 'Pular',
fix: 'Corrigir',
down: 'Parar',
up: 'Iniciar',
@ -118,6 +120,7 @@ const message = {
refreshRateUnit: 'Sem atualização | {n} segundo/atualização | {n} segundos/atualização',
selectColumn: 'Selecionar coluna',
local: 'Local',
serialNumber: 'Número de série',
},
loadingText: {
Upgrading: 'Atualizando o sistema, por favor, aguarde...',
@ -1379,6 +1382,10 @@ const message = {
fileCanNotRead: 'O arquivo não pode ser lido',
panelInstallDir: 'O diretório de instalação do 1Panel não pode ser excluído',
wgetTask: 'Tarefa de Download',
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)',
existFileDirHelper: 'O arquivo/pasta selecionado tem um nome duplicado. Por favor, prossiga com cautela!',
},
ssh: {
setting: 'configuração',

View file

@ -38,7 +38,7 @@ const message = {
collapse: 'Свернуть',
log: 'Логи',
back: 'Назад',
backup: 'Резервное копирование',
backup: 'Бэкап',
recover: 'Восстановить',
retry: 'Повторить',
upload: 'Загрузить',
@ -70,6 +70,8 @@ const message = {
createNewFile: 'Создать новый файл',
helpDoc: 'Справка',
unbind: 'Отвязать',
cover: 'Заменить',
skip: 'Пропустить',
fix: 'Исправить',
down: 'Остановить',
up: 'Запустить',
@ -118,6 +120,7 @@ const message = {
refreshRateUnit: 'Без обновления | {n} секунда/раз | {n} секунд/раз',
selectColumn: 'Выбрать столбец',
local: 'локальный',
serialNumber: 'Серийный номер',
},
loadingText: {
Upgrading: 'Обновление системы, пожалуйста, подождите...',
@ -1384,6 +1387,10 @@ const message = {
fileCanNotRead: 'Файл не может быть прочитан',
panelInstallDir: 'Директорию установки 1Panel нельзя удалить',
wgetTask: 'Задача загрузки',
existFileTitle: 'Предупреждение о файле с тем же именем',
existFileHelper: 'Загруженный файл содержит файл с таким же именем. Заменить его?',
existFileSize: 'Размер файла (новый -> старый)',
existFileDirHelper: 'Выбранный файл/папка имеет дублирующееся имя. Пожалуйста, действуйте осторожно!',
},
ssh: {
setting: 'настройка',

View file

@ -73,6 +73,8 @@ const message = {
helpDoc: '幫助文档',
bind: '綁定',
unbind: '解綁',
cover: '覆蓋',
skip: '跳過',
fix: '修復',
down: '停止',
up: '啟動',
@ -123,6 +125,7 @@ const message = {
refreshRateUnit: '{0} /',
selectColumn: '選擇列',
local: '本地',
serialNumber: '序號',
},
loadingText: {
Upgrading: '系統升級中請稍候...',
@ -1338,6 +1341,10 @@ const message = {
fileCanNotRead: '此文件不支持預覽',
panelInstallDir: '1Panel 安裝目錄不能删除',
wgetTask: '下載任務',
existFileTitle: '同名檔案提示',
existFileHelper: '上傳的檔案存在同名檔案是否覆蓋',
existFileSize: '文件大小->',
existFileDirHelper: '選擇的檔案/資料夾存在同名請謹慎操作',
},
ssh: {
autoStart: '開機自啟',

View file

@ -73,6 +73,8 @@ const message = {
helpDoc: '帮助文档',
bind: '绑定',
unbind: '解绑',
cover: '覆盖',
skip: '跳过',
fix: '修复',
down: '停止',
up: '启动',
@ -123,6 +125,7 @@ const message = {
refreshRateUnit: '{0} /',
selectColumn: '选择列',
local: '本地',
serialNumber: '序号',
},
loadingText: {
Upgrading: '系统升级中请稍候...',
@ -1334,6 +1337,10 @@ const message = {
fileCanNotRead: '此文件不支持预览',
panelInstallDir: '1Panel 安装目录不能删除',
wgetTask: '下载任务',
existFileTitle: '同名文件提示',
existFileHelper: '上传的文件存在同名文件是否覆盖',
existFileSize: '文件大小 ( -> )',
existFileDirHelper: '选择的文件/文件夹存在同名请谨慎操作',
},
ssh: {
autoStart: '开机自启',

View file

@ -55,6 +55,7 @@
:collapse-transition="false"
:unique-opened="true"
@select="handleMenuClick"
class="custom-menu"
>
<SubItem :menuList="routerMenus" />
<el-menu-item :index="''" @click="logout">

View file

@ -7,11 +7,7 @@
}
.a-detail {
height: 100%;
width: 100%;
.d-name {
height: 20%;
.name {
white-space: nowrap;
overflow: hidden;
@ -26,22 +22,15 @@
}
.h-button {
float: right;
margin-left: 5px;
}
.msg {
margin-left: 5px;
}
.el-button + .el-button {
margin-left: 6px;
}
}
.d-description {
margin-top: 10px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.el-tag {
margin-right: 5px;
}
@ -53,7 +42,7 @@
}
.d-button {
margin-top: 10px;
min-width: 440px;
min-width: 330px;
}
}
@ -66,6 +55,11 @@
}
}
.table-button {
display: inline;
margin-right: 5px;
}
.app-divider {
margin-top: 5px;
border: 0;
@ -87,7 +81,6 @@
}
.tag-button {
margin-right: 10px;
&.no-active {
background: none;
border: none;

View file

@ -96,7 +96,6 @@
:md="24"
:lg="12"
:xl="12"
:class="mode === 'upgrade' ? 'upgrade-card-col-12' : 'install-card-col-12'"
>
<div class="install-card">
<el-card class="e-card">
@ -113,122 +112,128 @@
<el-col :xs="24" :sm="21" :md="21" :lg="20" :xl="20">
<div class="a-detail">
<div class="d-name">
<el-button link type="info">
<el-tooltip :content="installed.name" placement="top">
<span class="name">{{ installed.name }}</span>
</el-tooltip>
</el-button>
<span class="status">
<Status
:key="installed.status"
:status="installed.status"
></Status>
</span>
<span class="msg">
<el-popover
v-if="isAppErr(installed)"
placement="bottom"
:width="400"
trigger="hover"
:content="installed.message"
:popper-options="options"
>
<template #reference>
<el-button link type="danger">
<el-icon><Warning /></el-icon>
</el-button>
</template>
<div class="app-error">
{{ installed.message }}
</div>
</el-popover>
</span>
<span class="ml-1">
<el-tooltip
effect="dark"
:content="$t('app.toFolder')"
placement="top"
>
<el-button
type="primary"
link
@click="toFolder(installed.path)"
>
<el-icon>
<FolderOpened />
</el-icon>
<div class="flex items-center justify-between">
<div class="min-w-50 flex items-center justify-start gap-1">
<el-button link type="info">
<el-tooltip :content="installed.name" placement="top">
<span class="name">{{ installed.name }}</span>
</el-tooltip>
</el-button>
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip
v-if="mode !== 'upgrade'"
effect="dark"
:content="$t('commons.button.log')"
placement="top"
>
<span class="status">
<Status
:key="installed.status"
:status="installed.status"
></Status>
</span>
<span class="msg">
<el-popover
v-if="isAppErr(installed)"
placement="bottom"
:width="400"
trigger="hover"
:content="installed.message"
:popper-options="options"
>
<template #reference>
<el-button link type="danger">
<el-icon><Warning /></el-icon>
</el-button>
</template>
<div class="app-error">
{{ installed.message }}
</div>
</el-popover>
</span>
<span class="ml-1">
<el-tooltip
effect="dark"
:content="$t('app.toFolder')"
placement="top"
>
<el-button
type="primary"
link
@click="toFolder(installed.path)"
>
<el-icon>
<FolderOpened />
</el-icon>
</el-button>
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip
v-if="mode !== 'upgrade'"
effect="dark"
:content="$t('commons.button.log')"
placement="top"
>
<el-button
type="primary"
link
@click="openLog(installed)"
:disabled="installed.status === 'DownloadErr'"
>
<el-icon><Tickets /></el-icon>
</el-button>
</el-tooltip>
</span>
</div>
<div class="flex flex-wrap items-center justify-end gap-1">
<el-button
type="primary"
link
@click="openLog(installed)"
:disabled="installed.status === 'DownloadErr'"
class="h-button"
plain
round
size="small"
@click="openUploads(installed.appKey, installed.name)"
v-if="mode === 'installed'"
>
<el-icon><Tickets /></el-icon>
{{ $t('database.loadBackup') }}
</el-button>
</el-tooltip>
</span>
<el-button
class="h-button"
plain
round
size="small"
@click="openUploads(installed.appKey, installed.name)"
v-if="mode === 'installed'"
>
{{ $t('database.loadBackup') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
@click="
openBackups(
installed.appKey,
installed.name,
installed.status,
)
"
v-if="mode === 'installed'"
>
{{ $t('commons.button.backup') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
:disabled="installed.status === 'Upgrading'"
@click="openOperate(installed, 'ignore')"
v-if="mode === 'upgrade'"
>
{{ $t('commons.button.ignore') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
:disabled="
(installed.status !== 'Running' &&
installed.status !== 'UpgradeErr') ||
installed.appStatus === 'TakeDown'
"
@click="openOperate(installed, 'upgrade')"
v-if="mode === 'upgrade'"
>
{{ $t('commons.button.upgrade') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
@click="
openBackups(
installed.appKey,
installed.name,
installed.status,
)
"
v-if="mode === 'installed'"
>
{{ $t('commons.button.backup') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
:disabled="installed.status === 'Upgrading'"
@click="openOperate(installed, 'ignore')"
v-if="mode === 'upgrade'"
>
{{ $t('commons.button.ignore') }}
</el-button>
<el-button
class="h-button"
plain
round
size="small"
:disabled="
(installed.status !== 'Running' &&
installed.status !== 'UpgradeErr') ||
installed.appStatus === 'TakeDown'
"
@click="openOperate(installed, 'upgrade')"
v-if="mode === 'upgrade'"
>
{{ $t('commons.button.upgrade') }}
</el-button>
</div>
</div>
</div>
<div
class="d-description flex flex-wrap items-center justify-start gap-1.5"
@ -295,13 +300,12 @@
{{ $t('app.webUIConfig') }}
</span>
</el-popover>
<div class="description">
<span>
{{ $t('app.alreadyRun') }}{{ $t('commons.colon') }}
{{ getAge(installed.createdAt) }}
</span>
</div>
</div>
<div class="description">
<span>
{{ $t('app.alreadyRun') }}{{ $t('commons.colon') }}
{{ getAge(installed.createdAt) }}
</span>
</div>
<div class="app-divider" />
<div
@ -729,38 +733,16 @@ onUnmounted(() => {
<style scoped lang="scss">
@use '../index';
@media only screen and (max-width: 1400px) {
.install-card-col-12 {
max-width: 100%;
flex: 0 0 100%;
.a-detail {
.d-name {
.name {
max-width: 300px;
}
}
}
}
}
@media only screen and (max-width: 1499px) {
.upgrade-card-col-12 {
max-width: 100%;
flex: 0 0 100%;
.a-detail {
.d-name {
.name {
max-width: 300px;
}
}
}
}
}
.app-error {
max-height: 500px;
overflow-y: auto;
}
.d-name {
.el-button + .el-button {
margin-left: 0;
}
}
.d-button {
.el-button + .el-button {
margin-left: 0;

View file

@ -391,7 +391,7 @@ const codeReq = reactive({ path: '', expand: false, page: 1, pageSize: 100 });
const fileUpload = reactive({ path: '' });
const fileRename = reactive({ path: '', oldName: '' });
const fileWget = reactive({ path: '' });
const fileMove = reactive({ oldPaths: [''], type: '', path: '', name: '', count: 0 });
const fileMove = reactive({ oldPaths: [''], allNames: [''], type: '', path: '', name: '', count: 0, isDir: false });
const createRef = ref();
const roleRef = ref();
@ -788,14 +788,23 @@ const openRename = (item: File.File) => {
const openMove = (type: string) => {
fileMove.type = type;
fileMove.name = '';
const oldpaths = [];
fileMove.allNames = [];
fileMove.isDir = false;
const oldPaths = [];
for (const s of selects.value) {
oldpaths.push(s['path']);
oldPaths.push(s['path']);
}
fileMove.count = selects.value.length;
fileMove.oldPaths = oldpaths;
fileMove.oldPaths = oldPaths;
if (selects.value.length == 1) {
fileMove.name = selects.value[0].name;
fileMove.isDir = selects.value[0].isDir;
} else {
const allNames = [];
for (const s of selects.value) {
allNames.push(s['name']);
}
fileMove.allNames = allNames;
}
moveOpen.value = true;
};
@ -806,6 +815,7 @@ const closeMove = () => {
fileMove.oldPaths = [];
fileMove.name = '';
fileMove.count = 0;
fileMove.isDir = false;
moveOpen.value = false;
};

View file

@ -1,5 +1,5 @@
<template>
<DrawerPro v-model="open" :header="title" @close="handleClose" size="normal">
<DrawerPro v-model="open" :header="title" @close="handleClose" size="675">
<el-form
@submit.prevent
ref="fileForm"
@ -22,6 +22,23 @@
<el-radio :value="false" size="large">{{ $t('file.rename') }}</el-radio>
</el-radio-group>
</div>
<div v-if="existFiles.length > 0 && !changeName" class="text-center">
<el-alert :show-icon="true" type="warning" :closable="false">
<div class="whitespace-break-spaces">
<span>{{ $t('file.existFileDirHelper') }}</span>
</div>
</el-alert>
<el-transfer
v-model="skipFiles"
class="text-left inline-block mt-4"
:titles="[$t('commons.button.cover'), $t('commons.button.skip')]"
:format="{
noChecked: '${total}',
hasChecked: '${checked}/${total}',
}"
:data="transferData"
/>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
@ -35,20 +52,22 @@
</template>
<script lang="ts" setup>
import { checkFile, moveFile } from '@/api/modules/files';
import { batchCheckFiles, checkFile, moveFile } from '@/api/modules/files';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { FormInstance, FormRules } from 'element-plus';
import { ref, reactive, computed } from 'vue';
import { ref, reactive, computed, ComputedRef } from 'vue';
import FileList from '@/components/file-list/index.vue';
import { MsgSuccess } from '@/utils/message';
import { getDateStr } from '@/utils/util';
interface MoveProps {
oldPaths: Array<string>;
allNames: Array<string>;
type: string;
path: string;
name: string;
isDir: boolean;
}
const fileForm = ref<FormInstance>();
@ -57,6 +76,9 @@ const open = ref(false);
const type = ref('cut');
const changeName = ref(false);
const oldName = ref('');
const existFiles = ref([]);
const skipFiles = ref([]);
const transferData = ref([]);
const title = computed(() => {
if (type.value === 'cut') {
@ -71,6 +93,8 @@ const addForm = reactive({
newPath: '',
type: '',
name: '',
allNames: [] as string[],
isDir: false,
cover: false,
});
@ -89,6 +113,18 @@ const handleClose = (search: boolean) => {
em('close', search);
};
const getFileName = (filePath: string) => {
if (filePath.endsWith('/')) {
filePath = filePath.slice(0, -1);
}
return filePath.split('/').pop();
};
const coverFiles: ComputedRef<string[]> = computed(() => {
return addForm.oldPaths.filter((item) => !skipFiles.value.includes(getFileName(item))).map((item) => item);
});
const getPath = (path: string) => {
addForm.newPath = path;
};
@ -97,7 +133,7 @@ const changeType = () => {
if (addForm.cover) {
addForm.name = oldName.value;
} else {
addForm.name = oldName.value + '-' + getDateStr();
addForm.name = renameFileWithSuffix(oldName.value, addForm.isDir);
}
};
@ -123,16 +159,64 @@ const submit = async (formEl: FormInstance | undefined) => {
return;
}
loading.value = true;
addForm.oldPaths = coverFiles.value;
mvFile();
});
};
const getCompleteExtension = (filename: string): string => {
const compoundExtensions = [
'.tar.gz',
'.tar.bz2',
'.tar.xz',
'.tar.lzma',
'.tar.Z',
'.tar.zst',
'.tar.lzo',
'.tar.sz',
'.tgz',
'.tbz2',
'.txz',
'.tzst',
];
const foundExtension = compoundExtensions.find((ext) => filename.endsWith(ext));
if (foundExtension) {
return foundExtension;
}
const match = filename.match(/\.[a-zA-Z0-9]+$/);
return match ? match[0] : '';
};
const renameFileWithSuffix = (fileName: string, isDir: boolean): string => {
const insertStr = '-' + getDateStr();
const completeExt = isDir ? '' : getCompleteExtension(fileName);
if (!completeExt) {
return `${fileName}${insertStr}`;
} else {
const baseName = fileName.slice(0, fileName.length - completeExt.length);
return `${baseName}${insertStr}${completeExt}`;
}
};
const handleFilePaths = async (fileNames: string[], newPath: string) => {
const uniqueFiles = [...new Set(fileNames)];
const fileNamesWithPath = uniqueFiles.map((file) => newPath + '/' + file);
const existData = await batchCheckFiles(fileNamesWithPath);
existFiles.value = existData.data;
transferData.value = existData.data.map((file) => ({
key: file.name,
label: file.name,
}));
};
const acceptParams = async (props: MoveProps) => {
changeName.value = false;
addForm.oldPaths = props.oldPaths;
addForm.type = props.type;
addForm.newPath = props.path;
addForm.isDir = props.isDir;
addForm.name = '';
addForm.allNames = props.allNames;
type.value = props.type;
if (props.name && props.name != '') {
oldName.value = props.name;
@ -140,7 +224,15 @@ const acceptParams = async (props: MoveProps) => {
if (res.data) {
changeName.value = true;
addForm.cover = false;
addForm.name = props.name + '-' + getDateStr();
addForm.name = renameFileWithSuffix(props.name, addForm.isDir);
open.value = true;
} else {
mvFile();
}
} else if (props.allNames && props.allNames.length > 0) {
await handleFilePaths(addForm.allNames, addForm.newPath);
if (existFiles.value.length > 0) {
changeName.value = false;
open.value = true;
} else {
mvFile();
@ -152,3 +244,41 @@ const acceptParams = async (props: MoveProps) => {
defineExpose({ acceptParams });
</script>
<style lang="scss" scoped>
:deep(.el-transfer) {
--el-transfer-panel-width: 250px;
.el-button {
padding: 4px 7px;
}
}
:deep(.el-transfer__buttons) {
padding: 5px 15px;
@media (max-width: 600px) {
width: 250px;
text-align: center;
padding: 10px 0;
.el-button [class*='el-icon'] svg {
transform: rotate(90deg);
}
}
@media (min-width: 601px) {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 40px;
height: 40px;
justify-content: center;
.el-button + .el-button {
margin-left: 0;
}
}
}
:deep(.el-transfer-panel .el-transfer-panel__footer) {
height: 65px;
}
</style>

View file

@ -91,16 +91,18 @@
</span>
</template>
</DrawerPro>
<ExistFileDialog ref="dialogExistFileRef" />
</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 { 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;
@ -112,6 +114,7 @@ let uploadPercent = ref(0);
const open = ref(false);
const path = ref();
let uploadHelper = ref('');
const dialogExistFileRef = ref();
const em = defineEmits(['close']);
const handleClose = () => {
@ -126,6 +129,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';
@ -262,87 +267,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;
};
@ -358,8 +401,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');
});
};

View file

@ -294,9 +294,11 @@ const languageOptions = ref([
...(!globalStore.isIntl ? [{ value: 'en', label: 'English' }] : []),
{ value: 'ja', label: '日本語' },
{ value: 'pt-BR', label: 'Português (Brasil)' },
{ value: 'ko', label: '한국어' },
{ value: 'ru', label: 'Русский' },
{ value: 'ms', label: 'Bahasa Melayu' },
]);
if (globalStore.isIntl) {
languageOptions.value.unshift({ value: 'en', label: 'English' });
}