feat(frontend): 增强文件管理器上传与右键菜单
新增“上传文件夹”入口,选择目录后先在浏览器端打包为 zip, 上传完成后自动触发远端解压并尝试清理临时压缩包。 同时重排文件右键菜单,补齐终端子菜单、复制文件名与绝对路径等操作, 并扩展上传任务状态展示。 同步前后端包版本到 1.0.0,并将设置页版本显示规范为 1.0
This commit is contained in:
@@ -13,6 +13,7 @@ import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } f
|
||||
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
|
||||
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
|
||||
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
|
||||
import { createFolderArchive } from '../composables/file-manager/useFolderArchiveUpload';
|
||||
import FileUploadPopup from './FileUploadPopup.vue';
|
||||
import FileManagerContextMenu from './FileManagerContextMenu.vue';
|
||||
import FileManagerActionModal from './FileManagerActionModal.vue';
|
||||
@@ -102,6 +103,9 @@ const {
|
||||
uploads,
|
||||
startFileUpload,
|
||||
cancelUpload,
|
||||
createUploadTask,
|
||||
updateUploadTask,
|
||||
cleanupUploadTask,
|
||||
} = useFileUploader(
|
||||
computed(() => props.sessionId),
|
||||
// 传递 manager 的 currentPath 和 fileList ref
|
||||
@@ -131,6 +135,7 @@ const {
|
||||
|
||||
// --- UI 状态 Refs (Remain mostly the same) ---
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const folderInputRef = ref<HTMLInputElement | null>(null);
|
||||
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||
const isEditingPath = ref(false);
|
||||
@@ -142,6 +147,7 @@ const pathInputRef = ref<HTMLInputElement | null>(null);
|
||||
const editablePath = ref('');
|
||||
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
|
||||
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
|
||||
const isFolderUploadBusy = ref(false);
|
||||
|
||||
// +++ Favorite Paths Modal State +++
|
||||
const showFavoritePathsModal = ref(false);
|
||||
@@ -869,6 +875,90 @@ const handlePaste = () => {
|
||||
|
||||
// --- 文件上传触发器 (定义在此处,供 Composable 使用) ---
|
||||
const triggerFileUpload = () => { fileInputRef.value?.click(); };
|
||||
const triggerFolderUpload = () => {
|
||||
if (isFolderUploadBusy.value) {
|
||||
return;
|
||||
}
|
||||
folderInputRef.value?.click();
|
||||
};
|
||||
|
||||
const getFolderUploadName = (files: File[]) => {
|
||||
const firstRelativePath = files[0]?.webkitRelativePath || files[0]?.name || 'folder';
|
||||
return firstRelativePath.split('/').filter(Boolean)[0] || 'folder';
|
||||
};
|
||||
|
||||
const handleFolderSelected = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
|
||||
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = getFolderUploadName(files);
|
||||
const uploadId = createUploadTask(folderName, {
|
||||
status: 'compressing',
|
||||
mode: 'folder-archive',
|
||||
detail: t('fileManager.notifications.folderArchiveQueued', { count: files.length }),
|
||||
});
|
||||
|
||||
isFolderUploadBusy.value = true;
|
||||
|
||||
try {
|
||||
const { archiveFile, entryCount } = await createFolderArchive(files, (progress) => {
|
||||
updateUploadTask(uploadId, {
|
||||
status: 'compressing',
|
||||
progress,
|
||||
detail: t('fileManager.notifications.folderArchivePreparing', { count: files.length }),
|
||||
});
|
||||
});
|
||||
|
||||
updateUploadTask(uploadId, {
|
||||
progress: 100,
|
||||
detail: t('fileManager.notifications.folderArchiveReady', { count: entryCount }),
|
||||
});
|
||||
|
||||
startFileUpload(archiveFile, undefined, {
|
||||
uploadId,
|
||||
displayName: folderName,
|
||||
mode: 'folder-archive',
|
||||
detail: t('fileManager.notifications.folderArchiveUploading', { count: entryCount }),
|
||||
afterUpload: async ({ remotePath }) => {
|
||||
if (!currentSftpManager.value) {
|
||||
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
|
||||
}
|
||||
|
||||
await currentSftpManager.value.decompressPath(remotePath, folderName);
|
||||
|
||||
try {
|
||||
await currentSftpManager.value.unlinkPath(remotePath);
|
||||
} catch (cleanupError: any) {
|
||||
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to cleanup uploaded archive ${remotePath}:`, cleanupError);
|
||||
uiNotificationsStore.showWarning(
|
||||
t('fileManager.errors.archiveCleanupFailed', {
|
||||
name: remotePath.split('/').pop() || remotePath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to archive folder before upload:`, error);
|
||||
updateUploadTask(uploadId, {
|
||||
status: 'error',
|
||||
error: error?.message || t('fileManager.errors.folderCompressionFailed'),
|
||||
});
|
||||
cleanupUploadTask(uploadId, 5000);
|
||||
} finally {
|
||||
isFolderUploadBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
|
||||
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
|
||||
@@ -1038,6 +1128,90 @@ const handleCopyPath = async (item: FileListItem) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFilename = async (item: FileListItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.filename);
|
||||
uiNotificationsStore.showSuccess(t('fileManager.notifications.filenameCopied', 'Filename copied to clipboard'));
|
||||
} catch (err) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to copy filename: `, err);
|
||||
uiNotificationsStore.showError(t('fileManager.errors.copyFilenameFailed', 'Failed to copy filename'));
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetPathForItem = (item?: FileListItem | null): string | null => {
|
||||
if (!currentSftpManager.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPath = currentSftpManager.value.currentPath.value;
|
||||
if (!item || item.filename === '..') {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
if (item.attrs.isDirectory) {
|
||||
return currentSftpManager.value.joinPath(currentPath, item.filename);
|
||||
}
|
||||
|
||||
return currentPath;
|
||||
};
|
||||
|
||||
const sendCdCommandToPath = (targetPath: string, sessionId?: string) => {
|
||||
if (!props.wsDeps.isConnected.value) {
|
||||
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: not connected.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedPath = `"${targetPath}"`;
|
||||
const command = `cd ${escapedPath}\n`;
|
||||
|
||||
try {
|
||||
const targetSession = sessionId ? sessionStore.sessions.get(sessionId) : sessionStore.activeSession;
|
||||
if (!targetSession?.terminalManager) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command: Terminal manager not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
targetSession.terminalManager.sendData(command);
|
||||
} catch (error) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command to terminal:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendItemPathToTerminal = (item: FileListItem) => {
|
||||
const targetPath = getTargetPathForItem(item);
|
||||
if (!targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendCdCommandToPath(targetPath);
|
||||
};
|
||||
|
||||
const handleOpenTerminalAtItemPath = (item: FileListItem) => {
|
||||
const targetPath = getTargetPathForItem(item);
|
||||
const connectionId = Number(props.dbConnectionId);
|
||||
if (!targetPath || Number.isNaN(connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousActiveSessionId = sessionStore.activeSessionId;
|
||||
sessionStore.handleOpenNewSession(connectionId);
|
||||
const newSessionId = sessionStore.activeSessionId;
|
||||
|
||||
if (!newSessionId || newSessionId === previousActiveSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession = sessionStore.sessions.get(newSessionId);
|
||||
if (!newSession?.wsManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = newSession.wsManager.onMessage('ssh:connected', () => {
|
||||
sendCdCommandToPath(targetPath, newSessionId);
|
||||
unregister?.();
|
||||
});
|
||||
};
|
||||
|
||||
// --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) ---
|
||||
const {
|
||||
contextMenuVisible,
|
||||
@@ -1065,6 +1239,7 @@ const {
|
||||
}
|
||||
},
|
||||
onUpload: triggerFileUpload,
|
||||
onUploadFolder: triggerFolderUpload,
|
||||
onDownload: triggerDownload,
|
||||
onDelete: handleDeleteSelectedClick,
|
||||
onRename: handleRenameContextMenuClick,
|
||||
@@ -1079,6 +1254,9 @@ const {
|
||||
onCompressRequest: handleCompress,
|
||||
onDecompressRequest: handleDecompress,
|
||||
onCopyPath: handleCopyPath, // +++ 传递复制路径回调 +++
|
||||
onCopyFilename: handleCopyFilename,
|
||||
onSendCdToTerminal: handleSendItemPathToTerminal,
|
||||
onOpenTerminalAtPath: handleOpenTerminalAtItemPath,
|
||||
});
|
||||
|
||||
// --- 目录加载与导航 ---
|
||||
@@ -1970,10 +2148,11 @@ watch(
|
||||
class="left-0 right-0 top-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div> <!-- End Wrapper -->
|
||||
</div> <!-- End Wrapper -->
|
||||
<!-- Main Actions Bar -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
|
||||
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
|
||||
<!-- 打开编辑器按钮 -->
|
||||
<button
|
||||
v-if="showPopupFileEditorBoolean"
|
||||
@@ -1995,7 +2174,17 @@ watch(
|
||||
:class="{ 'px-1.5': props.isMobile }"
|
||||
>
|
||||
<i class="fas fa-upload text-sm"></i>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.upload') }}</span>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="triggerFolderUpload"
|
||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
|
||||
:title="t('fileManager.actions.uploadFolder')"
|
||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
||||
:class="{ 'px-1.5': props.isMobile }"
|
||||
>
|
||||
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleNewFolderContextMenuClick"
|
||||
|
||||
@@ -133,6 +133,9 @@ onUnmounted(() => {
|
||||
const emit = defineEmits(['item-click', 'close-request']); // 添加 close-request
|
||||
|
||||
const handleItemClick = (item: ContextMenuItem) => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (item.action) {
|
||||
item.action(); // 只有当 action 存在时才执行
|
||||
emit('close-request'); // <-- 发出关闭请求
|
||||
@@ -221,13 +224,13 @@ onUnmounted(() => {
|
||||
<div
|
||||
ref="contextMenuRef"
|
||||
v-if="isVisible"
|
||||
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
|
||||
class="fixed bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.36)] rounded-xl z-[1002] min-w-[184px] overflow-hidden backdrop-blur-sm"
|
||||
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<ul class="list-none p-1 m-0">
|
||||
<ul class="list-none p-1.5 m-0">
|
||||
<template v-for="(menuItem, index) in items" :key="index">
|
||||
<li v-if="menuItem.separator" class="border-t border-border/50 my-1 mx-1"></li>
|
||||
<li v-if="menuItem.separator" class="border-t border-border/60 my-1.5 mx-2"></li>
|
||||
<!-- 如果是移动设备且有子菜单,则平铺子菜单 -->
|
||||
<template v-else-if="isMobile && menuItem.submenu && menuItem.submenu.length > 0">
|
||||
<li
|
||||
@@ -235,10 +238,15 @@ onUnmounted(() => {
|
||||
:key="`${index}-${subIndex}`"
|
||||
@click.stop="handleItemClick(subItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
subItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: subItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ subItem.label }}
|
||||
</li>
|
||||
<!-- 如果 menuItem (作为移动端子菜单容器) 是 "压缩", 在其子项后添加 "发送到" -->
|
||||
@@ -246,7 +254,7 @@ onUnmounted(() => {
|
||||
<li
|
||||
@click.stop="handleSendToClick"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
@@ -259,10 +267,15 @@ onUnmounted(() => {
|
||||
v-else-if="!menuItem.submenu"
|
||||
@click.stop="handleItemClick(menuItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
menuItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: menuItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ menuItem.label }}
|
||||
</li>
|
||||
<!-- 如果普通菜单项是 "压缩", 在其后添加 "发送到" -->
|
||||
@@ -279,15 +292,25 @@ onUnmounted(() => {
|
||||
</template>
|
||||
<li
|
||||
v-if="menuItem.submenu && !isMobile"
|
||||
class="px-4 py-1.5 text-foreground text-sm flex items-center justify-between transition-colors duration-150 rounded mx-1 hover:bg-primary/10 hover:text-primary relative"
|
||||
:class="[
|
||||
'px-3 py-2.5 text-sm flex items-center justify-between transition-colors duration-150 rounded-lg mx-0.5 relative',
|
||||
menuItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: menuItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
@mouseenter="showSubmenu(menuItem.label)"
|
||||
@mouseleave="hideSubmenu()"
|
||||
>
|
||||
{{ menuItem.label }}
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
<span class="truncate">{{ menuItem.label }}</span>
|
||||
</span>
|
||||
<span class="ml-2">›</span>
|
||||
<ul
|
||||
v-if="expandedSubmenu === menuItem.label"
|
||||
class="absolute left-full top-0 mt-0 ml-1 bg-background border border-border shadow-lg rounded-md z-[1003] min-w-[150px] list-none p-1"
|
||||
class="absolute left-full top-0 mt-0 ml-2 bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.32)] rounded-xl z-[1003] min-w-[184px] list-none p-1.5"
|
||||
@mouseenter="showSubmenu(menuItem.label)"
|
||||
@mouseleave="hideSubmenu()"
|
||||
>
|
||||
@@ -296,10 +319,15 @@ onUnmounted(() => {
|
||||
:key="subIndex"
|
||||
@click.stop="handleItemClick(subItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
subItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: subItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ subItem.label }}
|
||||
</li>
|
||||
</ul>
|
||||
@@ -309,7 +337,7 @@ onUnmounted(() => {
|
||||
<li
|
||||
@click.stop="handleSendToClick"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,14 @@ const uploadList = computed(() => Object.values(props.uploads).filter(upload =>
|
||||
return !isEffectivelySuccess && upload.status !== 'cancelled';
|
||||
}));
|
||||
|
||||
const showProgressBar = (upload: UploadItem) => {
|
||||
return ['compressing', 'pending', 'uploading'].includes(upload.status);
|
||||
};
|
||||
|
||||
const showProgressValue = (upload: UploadItem) => {
|
||||
return ['compressing', 'uploading'].includes(upload.status);
|
||||
};
|
||||
|
||||
const handleCancel = (uploadId: string) => {
|
||||
emit('cancel-upload', uploadId);
|
||||
};
|
||||
@@ -39,9 +47,14 @@ const handleCancel = (uploadId: string) => {
|
||||
<ul class="list-none p-0 m-0">
|
||||
<li v-for="upload in uploadList" :key="upload.id" class="mb-1.5 text-xs flex items-center flex-wrap gap-2">
|
||||
<span class="flex-grow truncate" :title="upload.filename">{{ upload.filename }} ({{ t(`fileManager.uploadStatus.${upload.status}`) }})</span>
|
||||
<progress v-if="(upload.status === 'uploading' && upload.progress < 100) || upload.status === 'pending'" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
|
||||
<span v-if="upload.status === 'uploading' && upload.progress < 100" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
|
||||
<span v-if="upload.detail" class="basis-full text-[11px] text-text-secondary">{{ upload.detail }}</span>
|
||||
<progress v-if="showProgressBar(upload)" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
|
||||
<span v-if="showProgressValue(upload)" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
|
||||
<span v-if="upload.status === 'error'" class="text-red-600 basis-full text-xs"> {{ t('fileManager.errors.generic') }}: {{ upload.error }}</span>
|
||||
<span v-if="upload.status === 'decompressing'" class="text-xs text-text-secondary flex items-center gap-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
{{ t('fileManager.uploadStatus.decompressing') }}
|
||||
</span>
|
||||
<span v-if="upload.status === 'success' || (upload.status === 'uploading' && upload.progress === 100)" class="text-green-600"> ✅</span>
|
||||
<span v-if="upload.status === 'cancelled'" class="text-red-600"> ❌ {{ t('fileManager.uploadStatus.cancelled') }}</span>
|
||||
<!-- 只有在可取消状态时显示取消按钮 -->
|
||||
|
||||
Reference in New Issue
Block a user