mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-10 21:21:13 +08:00
fix: Fix file loss issues when copying or moving large directories (#13524)
This commit is contained in:
11 files changed
+603
-127
No files matched your search
@@ -638,6 +638,26 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
// @Summary Stop file move task
|
||||
// @Accept json
|
||||
// @Param request body request.FileMoveStopReq true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /files/move/stop [post]
|
||||
func (b *BaseApi) StopMoveFile(c *gin.Context) {
|
||||
var req request.FileMoveStopReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := fileService.StopMvFile(req.TaskID); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
// @Summary Download file
|
||||
// @Accept json
|
||||
|
||||
@@ -131,6 +131,11 @@ type FileMove struct {
|
||||
Name string `json:"name"`
|
||||
Cover bool `json:"cover"`
|
||||
CoverPaths []string `json:"coverPaths"`
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
type FileMoveStopReq struct {
|
||||
TaskID string `json:"taskID" validate:"required"`
|
||||
}
|
||||
|
||||
type FileDownload struct {
|
||||
|
||||
+68
-15
@@ -52,6 +52,8 @@ type FileService struct {
|
||||
|
||||
const fileHistorySnapshotMaxSize = 10 * 1024 * 1024
|
||||
|
||||
var fileTransferLocks = newFileTransferLocks()
|
||||
|
||||
type IFileService interface {
|
||||
GetFileList(op request.FileOption) (response.FileInfo, error)
|
||||
SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error)
|
||||
@@ -72,6 +74,7 @@ type IFileService interface {
|
||||
ChangeName(req request.FileRename) error
|
||||
Wget(w request.FileWget) (string, error)
|
||||
MvFile(m request.FileMove) error
|
||||
StopMvFile(taskID string) error
|
||||
ChangeOwner(req request.FileRoleUpdate) error
|
||||
ChangeMode(op request.FileCreate) error
|
||||
BatchChangeModeAndOwner(op request.FileRoleReq) error
|
||||
@@ -908,17 +911,62 @@ func (f *FileService) Wget(w request.FileWget) (string, error) {
|
||||
|
||||
func (f *FileService) MvFile(m request.FileMove) error {
|
||||
fo := files.NewFileOp()
|
||||
if err := validateFileMove(fo, m); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.TaskID == "" {
|
||||
m.TaskID = common.GetUuid()
|
||||
}
|
||||
if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) {
|
||||
return buserr.New("TaskIsExecuting")
|
||||
}
|
||||
taskItem, err := task.NewTask(m.NewPath, task.TaskExec, task.TaskScopeTask, m.TaskID, 1)
|
||||
if err != nil {
|
||||
fileTransferLocks.Release(m.TaskID)
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
defer fileTransferLocks.Release(m.TaskID)
|
||||
taskItem.AddSubTaskWithOps(m.NewPath, func(t *task.Task) error {
|
||||
t.LogStart(m.NewPath)
|
||||
err := f.moveFileWithContext(t.TaskCtx, m)
|
||||
if err != nil && t.TaskCtx.Err() != nil {
|
||||
return t.TaskCtx.Err()
|
||||
}
|
||||
return err
|
||||
}, nil, 0, 0)
|
||||
_ = taskItem.Execute()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileService) StopMvFile(taskID string) error {
|
||||
if cancel, ok := global.LoadTaskCancel(taskID); ok {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
return buserr.New("TaskNotFound")
|
||||
}
|
||||
|
||||
func validateFileMove(fo files.FileOp, m request.FileMove) error {
|
||||
if !fo.Stat(m.NewPath) {
|
||||
return buserr.New("ErrPathNotFound")
|
||||
}
|
||||
for _, oldPath := range m.OldPaths {
|
||||
for _, oldPath := range append(append([]string{}, m.OldPaths...), m.CoverPaths...) {
|
||||
if !fo.Stat(oldPath) {
|
||||
return buserr.WithName("ErrFileNotFound", oldPath)
|
||||
}
|
||||
if oldPath == m.NewPath || strings.Contains(m.NewPath, filepath.Clean(oldPath)+"/") {
|
||||
oldPath = filepath.Clean(oldPath)
|
||||
newPath := filepath.Clean(m.NewPath)
|
||||
if oldPath == newPath || strings.HasPrefix(newPath, oldPath+string(filepath.Separator)) {
|
||||
return buserr.New("ErrMovePathFailed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileService) moveFileWithContext(ctx context.Context, m request.FileMove) error {
|
||||
fo := files.NewFileOp()
|
||||
type moveSnapshot struct {
|
||||
path string
|
||||
content []byte
|
||||
@@ -934,13 +982,25 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
}
|
||||
if len(m.CoverPaths) > 0 {
|
||||
for _, src := range m.CoverPaths {
|
||||
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
|
||||
errs = append(errs, err)
|
||||
global.LOG.Errorf("cut copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fo.DeleteDir(src); err != nil {
|
||||
removeErr := fmt.Errorf("remove merged source [%s] failed: %w", src, err)
|
||||
errs = append(errs, removeErr)
|
||||
global.LOG.Errorf("%s", removeErr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
|
||||
if err := fo.CutWithContext(ctx, m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
@@ -951,18 +1011,18 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return aggregateFileMoveErrors(errs)
|
||||
}
|
||||
if m.Type == "copy" {
|
||||
for _, src := range m.OldPaths {
|
||||
if err := fo.CopyAndReName(src, m.NewPath, m.Name, m.Cover); err != nil {
|
||||
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, m.Name, m.Cover); err != nil {
|
||||
errs = append(errs, err)
|
||||
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
|
||||
}
|
||||
}
|
||||
if len(m.CoverPaths) > 0 {
|
||||
for _, src := range m.CoverPaths {
|
||||
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
|
||||
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
|
||||
errs = append(errs, err)
|
||||
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
|
||||
}
|
||||
@@ -970,14 +1030,7 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
}
|
||||
}
|
||||
|
||||
var errString string
|
||||
for _, err := range errs {
|
||||
errString += err.Error() + "\n"
|
||||
}
|
||||
if errString != "" {
|
||||
return errors.New(errString)
|
||||
}
|
||||
return nil
|
||||
return aggregateFileMoveErrors(errs)
|
||||
}
|
||||
|
||||
func readEditableFileHistoryContent(filePath string) ([]byte, os.FileMode, bool) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
)
|
||||
|
||||
type fileTransferLockSet struct {
|
||||
mu sync.Mutex
|
||||
paths map[string][]string
|
||||
}
|
||||
|
||||
func newFileTransferLocks() *fileTransferLockSet {
|
||||
return &fileTransferLockSet{paths: make(map[string][]string)}
|
||||
}
|
||||
|
||||
func (s *fileTransferLockSet) Acquire(taskID string, transferPaths []string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, activePaths := range s.paths {
|
||||
for _, activePath := range activePaths {
|
||||
for _, transferPath := range transferPaths {
|
||||
if fileTransferPathsOverlap(activePath, transferPath) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.paths[taskID] = transferPaths
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *fileTransferLockSet) Release(taskID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.paths, taskID)
|
||||
}
|
||||
|
||||
func getFileTransferPaths(req request.FileMove) []string {
|
||||
paths := make([]string, 0, 1+len(req.OldPaths)+len(req.CoverPaths))
|
||||
paths = append(paths, req.NewPath)
|
||||
paths = append(paths, req.OldPaths...)
|
||||
paths = append(paths, req.CoverPaths...)
|
||||
|
||||
unique := make(map[string]struct{}, len(paths))
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
item = filepath.Clean(item)
|
||||
if _, ok := unique[item]; ok {
|
||||
continue
|
||||
}
|
||||
unique[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fileTransferPathsOverlap(first, second string) bool {
|
||||
return first == second || strings.HasPrefix(first, second+string(filepath.Separator)) || strings.HasPrefix(second, first+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func aggregateFileMoveErrors(errs []error) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var errString strings.Builder
|
||||
for _, err := range errs {
|
||||
errString.WriteString(err.Error())
|
||||
errString.WriteByte('\n')
|
||||
}
|
||||
return errors.New(errString.String())
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
fileRouter.POST("/wget", baseApi.WgetFile)
|
||||
fileRouter.POST("/wget/stop", baseApi.StopWget)
|
||||
fileRouter.POST("/move", baseApi.MoveFile)
|
||||
fileRouter.POST("/move/stop", baseApi.StopMoveFile)
|
||||
fileRouter.GET("/download", baseApi.Download)
|
||||
fileRouter.POST("/share/search", baseApi.SearchFileShare)
|
||||
fileRouter.POST("/share/detail", baseApi.GetFileShareDetail)
|
||||
|
||||
@@ -594,6 +594,12 @@ func (f FileOp) DownloadFile(url, dst string) error {
|
||||
}
|
||||
|
||||
func (f FileOp) Cut(oldPaths []string, dst, name string, cover bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cmdRecursiveTimeout)
|
||||
defer cancel()
|
||||
return f.CutWithContext(ctx, oldPaths, dst, name, cover)
|
||||
}
|
||||
|
||||
func (f FileOp) CutWithContext(ctx context.Context, oldPaths []string, dst, name string, cover bool) error {
|
||||
if len(oldPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -617,7 +623,7 @@ func (f FileOp) Cut(oldPaths []string, dst, name string, cover bool) error {
|
||||
}
|
||||
args = append(args, oldPaths...)
|
||||
args = append(args, dstPath)
|
||||
if err := cmd.NewCommandMgr(cmd.WithTimeout(cmdRecursiveTimeout)).Run("mv", args...); err != nil {
|
||||
if err := cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("mv", args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -233,6 +233,11 @@ export namespace File {
|
||||
name?: string;
|
||||
cover?: boolean;
|
||||
coverPaths?: string[];
|
||||
taskID?: string;
|
||||
}
|
||||
|
||||
export interface FileMoveStopReq {
|
||||
taskID: string;
|
||||
}
|
||||
|
||||
export interface FileDownload {
|
||||
|
||||
@@ -146,7 +146,16 @@ export const stopWgetFile = (key: string) => {
|
||||
};
|
||||
|
||||
export const moveFile = (params: File.FileMove) => {
|
||||
return http.post<File.File>('files/move', params, TimeoutEnum.T_5M);
|
||||
return http.post<File.File>('files/move', params);
|
||||
};
|
||||
|
||||
export const stopMoveFile = (taskID: string, currentNode?: string) => {
|
||||
return http.post(
|
||||
'files/move/stop',
|
||||
{ taskID } as File.FileMoveStopReq,
|
||||
undefined,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
export const downloadFile = (params: File.FileDownload) => {
|
||||
|
||||
@@ -143,6 +143,7 @@ const currentTaskID = ref('');
|
||||
const taskInfo = ref<Log.Task | null>(null);
|
||||
let taskTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const compressTaskKey = 'file-management-compress-task';
|
||||
const compressTypePreferenceKey = 'file-management-compress-type';
|
||||
|
||||
const em = defineEmits<{
|
||||
(e: 'close', value: boolean): void;
|
||||
@@ -159,7 +160,9 @@ const extension = computed(() => {
|
||||
return CompressExtension[form.value.type];
|
||||
});
|
||||
|
||||
const isTaskExecuting = computed(() => taskInfo.value?.status === 'Executing');
|
||||
const isTaskExecuting = computed(() => {
|
||||
return !!currentTaskID.value && (!taskInfo.value || taskInfo.value.status === 'Executing');
|
||||
});
|
||||
const showTaskStatus = computed(() =>
|
||||
Boolean(currentTaskID.value || loading.value || canceling.value || stopping.value),
|
||||
);
|
||||
@@ -201,17 +204,19 @@ const loadTaskInfo = async () => {
|
||||
},
|
||||
currentNode.value,
|
||||
);
|
||||
taskInfo.value = res.data.items?.[0] || null;
|
||||
const item = res.data.items?.[0];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
taskInfo.value = item;
|
||||
emitTaskChange();
|
||||
if (!taskInfo.value || taskInfo.value.status !== 'Executing') {
|
||||
if (taskInfo.value.status !== 'Executing') {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
} catch (error) {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,7 +244,6 @@ const resetDrawerState = () => {
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (currentTaskID.value && isTaskExecuting.value) {
|
||||
stopTaskPolling();
|
||||
open.value = false;
|
||||
em('close', false);
|
||||
return;
|
||||
@@ -298,6 +302,17 @@ const getLinkPath = (path: string) => {
|
||||
form.value.dst = path;
|
||||
};
|
||||
|
||||
const restoreCompressTypePreference = () => {
|
||||
const preferredType = localStorage.getItem(compressTypePreferenceKey);
|
||||
if (preferredType && Object.values(CompressType).includes(preferredType as CompressType)) {
|
||||
form.value.type = preferredType;
|
||||
}
|
||||
};
|
||||
|
||||
const saveCompressTypePreference = () => {
|
||||
localStorage.setItem(compressTypePreferenceKey, form.value.type);
|
||||
};
|
||||
|
||||
const submit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate((valid) => {
|
||||
@@ -316,6 +331,7 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
signal: abortController.value?.signal,
|
||||
})
|
||||
.then(() => {
|
||||
saveCompressTypePreference();
|
||||
currentTaskID.value = taskID;
|
||||
loading.value = false;
|
||||
taskInfo.value = null;
|
||||
@@ -345,14 +361,17 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
});
|
||||
};
|
||||
|
||||
const acceptParams = (props: CompressProps) => {
|
||||
const acceptParams = async (props: CompressProps) => {
|
||||
if (currentTaskID.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
await loadTaskInfo();
|
||||
if (currentTaskID.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
}
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
|
||||
form.value.files = props.files;
|
||||
@@ -393,9 +412,7 @@ const restoreRunningTask = () => {
|
||||
return;
|
||||
}
|
||||
currentTaskID.value = task.taskID;
|
||||
if (task.status === 'Executing') {
|
||||
emitTaskChange();
|
||||
} else {
|
||||
if (task.status !== 'Executing') {
|
||||
localStorage.removeItem(compressTaskKey);
|
||||
}
|
||||
} catch {
|
||||
@@ -404,14 +421,16 @@ const restoreRunningTask = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
restoreCompressTypePreference();
|
||||
restoreRunningTask();
|
||||
if (currentTaskID.value) {
|
||||
startTaskPolling();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val && currentTaskID.value) {
|
||||
if (val && currentTaskID.value && !taskTimer) {
|
||||
startTaskPolling();
|
||||
} else {
|
||||
stopTaskPolling();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ const em = defineEmits<{
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const isTaskExecuting = computed(() => taskInfo.value?.status === 'Executing');
|
||||
const isTaskExecuting = computed(() => {
|
||||
return !!currentTaskID.value && (!taskInfo.value || taskInfo.value.status === 'Executing');
|
||||
});
|
||||
const showTaskStatus = computed(() =>
|
||||
Boolean(currentTaskID.value || loading.value || canceling.value || stopping.value),
|
||||
);
|
||||
@@ -183,17 +185,19 @@ const loadTaskInfo = async () => {
|
||||
},
|
||||
currentNode.value,
|
||||
);
|
||||
taskInfo.value = res.data.items?.[0] || null;
|
||||
const item = res.data.items?.[0];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
taskInfo.value = item;
|
||||
emitTaskChange();
|
||||
if (!taskInfo.value || taskInfo.value.status !== 'Executing') {
|
||||
if (taskInfo.value.status !== 'Executing') {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
} catch {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,7 +225,6 @@ const resetDrawerState = () => {
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (currentTaskID.value && isTaskExecuting.value) {
|
||||
stopTaskPolling();
|
||||
open.value = false;
|
||||
em('close', false);
|
||||
return;
|
||||
@@ -323,14 +326,17 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
});
|
||||
};
|
||||
|
||||
const acceptParams = (props: DecompressProps) => {
|
||||
const acceptParams = async (props: DecompressProps) => {
|
||||
if (currentTaskID.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
await loadTaskInfo();
|
||||
if (currentTaskID.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
}
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
|
||||
form.value.type = props.type;
|
||||
@@ -365,9 +371,7 @@ const restoreRunningTask = () => {
|
||||
return;
|
||||
}
|
||||
currentTaskID.value = task.taskID;
|
||||
if (task.status === 'Executing') {
|
||||
emitTaskChange();
|
||||
} else {
|
||||
if (task.status !== 'Executing') {
|
||||
localStorage.removeItem(decompressTaskKey);
|
||||
}
|
||||
} catch {
|
||||
@@ -377,13 +381,14 @@ const restoreRunningTask = () => {
|
||||
|
||||
onMounted(() => {
|
||||
restoreRunningTask();
|
||||
if (currentTaskID.value) {
|
||||
startTaskPolling();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val && currentTaskID.value) {
|
||||
if (val && currentTaskID.value && !taskTimer) {
|
||||
startTaskPolling();
|
||||
} else {
|
||||
stopTaskPolling();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,81 +1,132 @@
|
||||
<template>
|
||||
<DrawerPro v-model="open" :header="title" @close="handleClose" size="690">
|
||||
<el-form
|
||||
@submit.prevent
|
||||
ref="fileForm"
|
||||
label-position="top"
|
||||
:model="addForm"
|
||||
:rules="rules"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-alert
|
||||
v-if="type == 'cut' && existFiles?.length == 0 && addForm.cover && changeName"
|
||||
show-icon
|
||||
type="warning"
|
||||
:closable="false"
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="showTaskStatus"
|
||||
class="rounded-lg border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-light)] px-4 py-3"
|
||||
>
|
||||
<template #title>
|
||||
<span class="whitespace-break-spaces">{{ $t('file.coverDirHelper') }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item :label="$t('file.path')" prop="newPath">
|
||||
<el-input v-model="addForm.newPath">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<div v-if="changeName">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model="addForm.name" :disabled="addForm.cover"></el-input>
|
||||
</el-form-item>
|
||||
<el-radio-group v-model="addForm.cover" @change="changeType">
|
||||
<el-radio :value="true" size="large">{{ $t('file.replace') }}</el-radio>
|
||||
<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 type="warning" :closable="false">
|
||||
<template #title>
|
||||
<span class="whitespace-break-spaces">
|
||||
{{ $t('file.existFileDirHelper') + $t('file.coverDirHelper') }}
|
||||
</span>
|
||||
</template>
|
||||
</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 class="flex items-center justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('commons.status.executing') }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ taskInfo?.name || title }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openTaskLog" :disabled="!currentTaskID">
|
||||
{{ $t('commons.button.log') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-progress
|
||||
class="mt-3"
|
||||
:percentage="100"
|
||||
:indeterminate="true"
|
||||
:duration="1"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<el-form
|
||||
v-if="!currentTaskID"
|
||||
@submit.prevent
|
||||
ref="fileForm"
|
||||
label-position="top"
|
||||
:model="addForm"
|
||||
:rules="rules"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-alert
|
||||
v-if="type == 'cut' && existFiles?.length == 0 && addForm.cover && changeName"
|
||||
show-icon
|
||||
type="warning"
|
||||
:closable="false"
|
||||
>
|
||||
<template #title>
|
||||
<span class="whitespace-break-spaces">{{ $t('file.coverDirHelper') }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item :label="$t('file.path')" prop="newPath">
|
||||
<el-input v-model="addForm.newPath">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<div v-if="changeName">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model="addForm.name" :disabled="addForm.cover"></el-input>
|
||||
</el-form-item>
|
||||
<el-radio-group v-model="addForm.cover" @change="changeType">
|
||||
<el-radio :value="true" size="large">{{ $t('file.replace') }}</el-radio>
|
||||
<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 type="warning" :closable="false">
|
||||
<template #title>
|
||||
<span class="whitespace-break-spaces">
|
||||
{{ $t('file.existFileDirHelper') + $t('file.coverDirHelper') }}
|
||||
</span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose(false)" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="submit(fileForm)" :disabled="loading">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
<template v-if="!currentTaskID">
|
||||
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="submit(fileForm)" :disabled="loading">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
v-if="isTaskExecuting || loading"
|
||||
type="danger"
|
||||
:loading="stopping"
|
||||
@click="stopCurrentTask"
|
||||
>
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button @click="openTaskLog">{{ $t('commons.button.log') }}</el-button>
|
||||
<el-button type="default" @click="closeDrawer">{{ $t('commons.button.close') }}</el-button>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<FileList ref="fileRef" @choose="getPath" />
|
||||
<TaskLog ref="taskLogRef" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { batchCheckFiles, checkFile, moveFile } from '@/api/modules/files';
|
||||
import { batchCheckFiles, checkFile, moveFile, stopMoveFile } from '@/api/modules/files';
|
||||
import { File } from '@/api/interface/file';
|
||||
import { Log } from '@/api/interface/log';
|
||||
import { searchTasks } from '@/api/modules/log';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import i18n from '@/lang';
|
||||
import { FormInstance, FormRules } from 'element-plus';
|
||||
import { ref, reactive, computed, ComputedRef } from 'vue';
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { ref, reactive, computed, ComputedRef, onMounted, onUnmounted } from 'vue';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { getDateStr } from '@/utils/date';
|
||||
import { File } from '@/api/interface/file';
|
||||
import { getErrorMessage } from '@/utils/misc';
|
||||
import { newUUID } from '@/utils/id';
|
||||
|
||||
interface MoveProps {
|
||||
oldPaths: Array<string>;
|
||||
@@ -96,6 +147,17 @@ const existFiles = ref<File.ExistFileInfo[]>([]);
|
||||
const skipFiles = ref([]);
|
||||
const transferData = ref([]);
|
||||
const fileRef = ref();
|
||||
const taskLogRef = ref<InstanceType<typeof TaskLog> | null>(null);
|
||||
const currentTaskID = ref('');
|
||||
const currentTaskNode = ref('');
|
||||
const taskInfo = ref<Log.Task | null>(null);
|
||||
const stopping = ref(false);
|
||||
const canceling = ref(false);
|
||||
let taskTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const moveTaskKey = 'file-management-move-task';
|
||||
const { currentNode } = useGlobalStore();
|
||||
|
||||
const getMoveTaskKey = (node: string) => `${moveTaskKey}:${node}`;
|
||||
|
||||
const title = computed(() => {
|
||||
if (type.value === 'cut') {
|
||||
@@ -123,12 +185,168 @@ const rules = reactive<FormRules>({
|
||||
|
||||
const em = defineEmits(['close', 'loading']);
|
||||
|
||||
const handleClose = (search: boolean) => {
|
||||
open.value = false;
|
||||
const isTaskExecuting = computed(() => {
|
||||
return !!currentTaskID.value && (!taskInfo.value || taskInfo.value.status === 'Executing');
|
||||
});
|
||||
|
||||
const showTaskStatus = computed(() => {
|
||||
return !!currentTaskID.value && (loading.value || isTaskExecuting.value);
|
||||
});
|
||||
|
||||
const syncTaskStorage = () => {
|
||||
const status = taskInfo.value?.status || (currentTaskID.value || loading.value ? 'Executing' : '');
|
||||
const node = currentTaskNode.value || currentNode.value;
|
||||
const storageKey = getMoveTaskKey(node);
|
||||
if (currentTaskID.value && status === 'Executing') {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ taskID: currentTaskID.value, status, type: type.value, node }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(storageKey);
|
||||
};
|
||||
|
||||
const stopTaskPolling = () => {
|
||||
if (taskTimer) {
|
||||
clearInterval(taskTimer);
|
||||
taskTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearCurrentTask = () => {
|
||||
stopTaskPolling();
|
||||
localStorage.removeItem(getMoveTaskKey(currentTaskNode.value || currentNode.value));
|
||||
currentTaskID.value = '';
|
||||
currentTaskNode.value = '';
|
||||
taskInfo.value = null;
|
||||
loading.value = false;
|
||||
stopping.value = false;
|
||||
canceling.value = false;
|
||||
};
|
||||
|
||||
const resetDrawerState = () => {
|
||||
if (fileForm.value) {
|
||||
fileForm.value.resetFields();
|
||||
}
|
||||
em('close', search);
|
||||
changeName.value = false;
|
||||
oldName.value = '';
|
||||
existFiles.value = [];
|
||||
skipFiles.value = [];
|
||||
transferData.value = [];
|
||||
addForm.oldPaths = [];
|
||||
addForm.newPath = '';
|
||||
addForm.type = '';
|
||||
addForm.name = '';
|
||||
addForm.allNames = [];
|
||||
addForm.isDir = false;
|
||||
addForm.cover = false;
|
||||
addForm.coverPaths = [];
|
||||
clearCurrentTask();
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (isTaskExecuting.value) {
|
||||
open.value = false;
|
||||
return;
|
||||
}
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (loading.value) {
|
||||
void stopCurrentTask();
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const loadTaskInfo = async () => {
|
||||
if (!currentTaskID.value) {
|
||||
return;
|
||||
}
|
||||
const taskID = currentTaskID.value;
|
||||
const taskNode = currentTaskNode.value || currentNode.value;
|
||||
try {
|
||||
const res = await searchTasks(
|
||||
{
|
||||
taskID,
|
||||
type: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
},
|
||||
taskNode,
|
||||
);
|
||||
if (currentTaskID.value !== taskID || currentTaskNode.value !== taskNode) {
|
||||
return;
|
||||
}
|
||||
const item = res.data.items?.[0];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
taskInfo.value = item;
|
||||
syncTaskStorage();
|
||||
if (item.status === 'Executing') {
|
||||
return;
|
||||
}
|
||||
stopTaskPolling();
|
||||
if (item.status === 'Success') {
|
||||
if (type.value === 'cut') {
|
||||
MsgSuccess(i18n.global.t('file.moveSuccess'));
|
||||
} else {
|
||||
MsgSuccess(i18n.global.t('file.copySuccess'));
|
||||
}
|
||||
resetDrawerState();
|
||||
em('close', true);
|
||||
return;
|
||||
}
|
||||
if (item.status === 'Canceled') {
|
||||
resetDrawerState();
|
||||
em('close', true);
|
||||
return;
|
||||
}
|
||||
const errorMessage = item.errorMsg || i18n.global.t('commons.msg.operationFailed');
|
||||
clearCurrentTask();
|
||||
MsgError(errorMessage);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const startTaskPolling = () => {
|
||||
stopTaskPolling();
|
||||
void loadTaskInfo();
|
||||
taskTimer = setInterval(() => {
|
||||
void loadTaskInfo();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const openTaskLog = () => {
|
||||
if (!currentTaskID.value) {
|
||||
return;
|
||||
}
|
||||
taskLogRef.value?.openWithTaskID(currentTaskID.value, true, currentTaskNode.value || currentNode.value);
|
||||
};
|
||||
|
||||
const stopCurrentTask = async () => {
|
||||
if (!currentTaskID.value || stopping.value) {
|
||||
return;
|
||||
}
|
||||
if (loading.value) {
|
||||
canceling.value = true;
|
||||
return;
|
||||
}
|
||||
stopping.value = true;
|
||||
try {
|
||||
await stopMoveFile(currentTaskID.value, currentTaskNode.value || currentNode.value);
|
||||
} catch (error) {
|
||||
MsgError(getErrorMessage(error));
|
||||
} finally {
|
||||
stopping.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (filePath: string) => {
|
||||
@@ -169,16 +387,32 @@ const changeType = () => {
|
||||
};
|
||||
|
||||
const mvFile = () => {
|
||||
const taskID = newUUID();
|
||||
currentTaskID.value = taskID;
|
||||
currentTaskNode.value = currentNode.value;
|
||||
taskInfo.value = null;
|
||||
open.value = true;
|
||||
loading.value = true;
|
||||
em('loading', true);
|
||||
moveFile(addForm)
|
||||
.then(() => {
|
||||
if (type.value === 'cut') {
|
||||
MsgSuccess(i18n.global.t('file.moveSuccess'));
|
||||
} else {
|
||||
MsgSuccess(i18n.global.t('file.copySuccess'));
|
||||
syncTaskStorage();
|
||||
moveFile({ ...addForm, taskID })
|
||||
.then(async () => {
|
||||
loading.value = false;
|
||||
if (canceling.value) {
|
||||
stopping.value = true;
|
||||
try {
|
||||
await stopMoveFile(taskID, currentTaskNode.value || currentNode.value);
|
||||
} catch (error) {
|
||||
MsgError(getErrorMessage(error));
|
||||
} finally {
|
||||
stopping.value = false;
|
||||
}
|
||||
}
|
||||
handleClose(true);
|
||||
startTaskPolling();
|
||||
})
|
||||
.catch((error) => {
|
||||
clearCurrentTask();
|
||||
MsgError(getErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
@@ -192,7 +426,6 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
addForm.coverPaths = coverFiles.value;
|
||||
addForm.oldPaths = mvFiles.value;
|
||||
mvFile();
|
||||
@@ -245,6 +478,16 @@ const handleFilePaths = async (fileNames: string[], newPath: string) => {
|
||||
};
|
||||
|
||||
const acceptParams = async (props: MoveProps) => {
|
||||
if (currentTaskID.value) {
|
||||
if (isTaskExecuting.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
}
|
||||
return;
|
||||
}
|
||||
resetDrawerState();
|
||||
}
|
||||
changeName.value = false;
|
||||
addForm.oldPaths = props.oldPaths;
|
||||
addForm.type = props.type;
|
||||
@@ -279,6 +522,39 @@ const acceptParams = async (props: MoveProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const restoreTask = () => {
|
||||
const node = currentNode.value;
|
||||
const storageKey = getMoveTaskKey(node);
|
||||
const taskText = localStorage.getItem(storageKey);
|
||||
if (!taskText) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = JSON.parse(taskText) as { taskID?: string; status?: string; type?: string; node?: string };
|
||||
if (!task.taskID || task.status !== 'Executing' || task.node !== node) {
|
||||
localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
currentTaskID.value = task.taskID;
|
||||
currentTaskNode.value = node;
|
||||
if (task.type === 'copy' || task.type === 'cut') {
|
||||
type.value = task.type;
|
||||
}
|
||||
taskInfo.value = null;
|
||||
startTaskPolling();
|
||||
} catch {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
restoreTask();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTaskPolling();
|
||||
});
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
|
||||
Reference in new issue
Block a user