fix: Fix file loss issues when copying or moving large directories (#13524)

This commit is contained in:
蘭 authored and GitHub committed 2026-08-10 22:07:45 +08:00
1 parent d663a4397a
commit 1b76c91e1b
11 files changed
+603 -127

No files matched your search

+20
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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) {
+77
View File
@@ -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())
}
+1
View File
@@ -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)
+7 -1
View File
@@ -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
+5
View File
@@ -233,6 +233,11 @@ export namespace File {
name?: string;
cover?: boolean;
coverPaths?: string[];
taskID?: string;
}
export interface FileMoveStopReq {
taskID: string;
}
export interface FileDownload {
+10 -1
View File
@@ -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>