This commit is contained in:
Baobhan Sith
2025-05-16 16:43:56 +08:00
parent a4893e5625
commit c91e44cb32
13 changed files with 1542 additions and 6 deletions
@@ -677,6 +677,7 @@ const {
contextMenuPosition,
contextMenuItems,
contextMenuRef, // 获取 ref 以传递给子组件
contextTargetItem, // Get the target item from the composable
showContextMenu, // 使用 Composable 提供的函数
hideContextMenu, // <-- 获取 hideContextMenu 函数
} = useFileManagerContextMenu({
@@ -1617,6 +1618,8 @@ const handleOpenEditorClick = () => {
:is-visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
:active-context-item="contextTargetItem"
:current-directory-path="currentSftpManager?.currentPath?.value ?? '/'"
@close-request="hideContextMenu"
/>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, type PropType } from 'vue';
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
import { onUnmounted } from 'vue';
import { ref, watch, nextTick, type PropType, onUnmounted } from 'vue'; // Added watch, nextTick
import { useI18n } from 'vue-i18n';
import SendFilesModal from './SendFilesModal.vue';
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
import type { FileListItem } from '../types/sftp.types'; // Import FileListItem
import { useDeviceDetection } from '../composables/useDeviceDetection';
defineProps({
const props = defineProps({
isVisible: {
type: Boolean,
required: true,
@@ -17,9 +19,82 @@ defineProps({
type: Array as PropType<ContextMenuItem[]>,
required: true,
},
activeContextItem: { // Item that was right-clicked
type: Object as PropType<FileListItem | null>,
default: null,
},
currentDirectoryPath: { // Current path of the file manager
type: String,
required: true,
}
});
const { isMobile } = useDeviceDetection();
const { t } = useI18n();
const showSendFilesModal = ref(false);
// Update the type for itemsToSendData
const itemsToSendData = ref<{ name: string; path: string; type: 'file' | 'directory' }[]>([]);
// +++ 新增:用于菜单位置调整的 ref +++
const contextMenuRef = ref<HTMLDivElement | null>(null);
const computedRenderPosition = ref({ x: props.position.x, y: props.position.y });
watch(
[() => props.isVisible, () => props.position],
([newIsVisible, newPosition], [oldIsVisible, oldPosition]) => {
if (newIsVisible) {
// 仅当菜单从不可见变为可见,或当菜单可见时其初始位置改变时,才进行位置计算
// oldPosition 可能为 undefined,所以需要检查
const positionChangedWhileVisible = oldIsVisible && oldPosition && (newPosition.x !== oldPosition.x || newPosition.y !== oldPosition.y);
if (!oldIsVisible || positionChangedWhileVisible) {
computedRenderPosition.value = { ...newPosition }; // 设置初始位置为当前点击位置
nextTick(() => {
if (contextMenuRef.value) {
const menuElement = contextMenuRef.value;
const menuRect = menuElement.getBoundingClientRect();
// 如果菜单没有实际尺寸 (例如,内容为空或未渲染),则不进行调整
if (menuRect.width === 0 && menuRect.height === 0) {
// console.debug("[FileManagerContextMenu] Menu dimensions are zero, sticking to initial position.");
return;
}
let finalX = newPosition.x;
let finalY = newPosition.y;
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const margin = 10; // 距离窗口边缘的最小间距
// console.debug(`[FileManagerContextMenu] Initial pos: (${finalX}, ${finalY}), Menu size: (${menuWidth}x${menuHeight}), Window: (${window.innerWidth}x${window.innerHeight})`);
// 调整水平位置,防止溢出右侧
if (finalX + menuWidth > window.innerWidth) {
finalX = window.innerWidth - menuWidth - margin;
}
// 调整垂直位置,防止溢出底部
if (finalY + menuHeight > window.innerHeight) {
finalY = window.innerHeight - menuHeight - margin;
}
// 确保菜单不超出屏幕左上角
finalX = Math.max(margin, finalX);
finalY = Math.max(margin, finalY);
// console.debug(`[FileManagerContextMenu] Adjusted pos: (${finalX}, ${finalY})`);
computedRenderPosition.value = { x: finalX, y: finalY };
}
});
}
} else {
// 如果菜单不可见,确保 computedRenderPosition 与 props.position 同步,为下次显示做准备
computedRenderPosition.value = { ...newPosition };
}
},
{ deep: true, immediate: true } // immediate 确保初始状态(如果isVisible为true)也设置正确
);
// 隐藏菜单的逻辑由 useFileManagerContextMenu 中的全局点击监听器处理
// 但我们仍然需要触发菜单项的 action,并通知父组件关闭菜单
@@ -32,6 +107,43 @@ const handleItemClick = (item: ContextMenuItem) => {
}
};
const handleSendToClick = () => {
if (props.activeContextItem) {
const item = props.activeContextItem;
const type = item.attrs.isDirectory ? 'directory' : 'file';
// Ensure path is constructed correctly, assuming currentDirectoryPath ends with / or is root '/'
// And filename does not start with /
let fullPath = props.currentDirectoryPath;
if (!fullPath.endsWith('/')) {
fullPath += '/';
}
fullPath += item.filename;
// Normalize path to remove any double slashes, except for protocol like sftp://
fullPath = fullPath.replace(/(?<!:)\/\//g, '/');
itemsToSendData.value = [{
name: item.filename,
path: fullPath,
type: type,
}];
} else {
// No specific item clicked, perhaps send selected items? Or disable "Send To"?
// For now, sending an empty array if no activeContextItem.
// This scenario should ideally be handled by disabling the "Send to..." option
// if no item is targeted or no selection is made that can be sent.
itemsToSendData.value = [];
}
showSendFilesModal.value = true;
emit('close-request');
};
const handleFilesSent = (payload: any) => {
console.log('Files to send (from FileManagerContextMenu):', payload);
// 实际发送逻辑可以后续添加或委派
};
// 管理二级菜单的展开状态
@@ -62,9 +174,10 @@ onUnmounted(() => {
<template>
<div
ref="contextMenuRef"
v-if="isVisible"
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
:style="{ top: `${position.y}px`, left: `${position.x}px` }"
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
@click.stop
>
<ul class="list-none p-1 m-0">
@@ -123,6 +236,20 @@ onUnmounted(() => {
</ul>
</li>
</template>
<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',
'hover:bg-primary/10 hover:text-primary'
]"
>
{{ t('fileManager.contextMenu.sendTo', 'Send to...') }}
</li>
</ul>
</div>
<SendFilesModal
v-model:visible="showSendFilesModal"
:items-to-send="itemsToSendData"
@send="handleFilesSent"
/>
</template>
@@ -0,0 +1,351 @@
<template>
<div
v-if="visible"
class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4"
@click.self="handleCancel"
>
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="flex justify-between items-center pb-4 mb-4 border-b border-border flex-shrink-0">
<h3 class="text-xl font-semibold">
{{ t('sendFilesModal.title') }}
</h3>
<button
@click="handleCancel"
class="text-text-secondary hover:text-foreground transition-colors"
aria-label="Close modal"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Body -->
<div class="flex-grow overflow-y-auto pr-1 space-y-4">
<!-- Top Section: Search, Target Path, Transfer Method -->
<div class="space-y-4">
<input
type="text"
:placeholder="t('sendFilesModal.searchConnectionsPlaceholder')"
v-model="searchTerm"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/>
<div class="flex flex-col sm:flex-row gap-4">
<div class="form-group flex-1">
<label for="targetPath" class="block text-sm font-medium text-text-secondary mb-1">{{ t('sendFilesModal.targetPathLabel') }}</label>
<input
type="text"
id="targetPath"
v-model="targetPath"
:placeholder="t('sendFilesModal.targetPathPlaceholder')"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/>
</div>
<div class="form-group sm:w-48">
<label for="transferMethod" class="block text-sm font-medium text-text-secondary mb-1">{{ t('sendFilesModal.transferMethodLabel') }}</label>
<select
id="transferMethod"
v-model="transferMethod"
class="form-select w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
>
<option value="auto">{{ t('sendFilesModal.transferMethodAuto') }}</option>
<option value="rsync">rsync</option>
<option value="scp">scp</option>
</select>
</div>
</div>
</div>
<!-- Connections Section -->
<div class="border border-border rounded-md p-4 space-y-3 max-h-72 overflow-y-auto bg-muted/30">
<div v-if="isLoadingConnections || isLoadingTags" class="flex items-center justify-center h-24 text-text-secondary">
<i class="fas fa-spinner fa-spin mr-2"></i> {{ t('sendFilesModal.loadingConnections') }}
</div>
<div v-else-if="filteredGroupedConnections.length === 0 && !searchTerm" class="flex flex-col items-center justify-center h-24 text-text-secondary">
<i class="fas fa-folder-open text-2xl mb-2"></i>
<p>{{ t('sendFilesModal.noConnections') }}</p>
</div>
<div v-else-if="filteredGroupedConnections.length === 0 && searchTerm" class="flex flex-col items-center justify-center h-24 text-text-secondary">
<i class="fas fa-search text-2xl mb-2"></i>
<p>{{ t('sendFilesModal.noConnectionsFound') }}</p>
</div>
<div v-else class="space-y-3">
<div v-for="group in filteredGroupedConnections" :key="group.tag ? group.tag.id : 'untagged'" class="tag-group">
<div class="flex items-center py-1.5">
<input
type="checkbox"
:id="'tag-' + (group.tag ? group.tag.id : 'untagged')"
:checked="isTagGroupSelected(group)"
:indeterminate="isTagGroupIndeterminate(group)"
@change="toggleTagGroupSelection(group)"
class="mr-2 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer"
/>
<label :for="'tag-' + (group.tag ? group.tag.id : 'untagged')" class="font-semibold text-foreground select-none cursor-pointer text-sm">
{{ group.tag ? group.tag.name : t('sendFilesModal.untaggedConnections') }} ({{ group.connections.length }})
</label>
</div>
<ul class="pl-7 space-y-0.5">
<li v-for="connection in group.connections" :key="connection.id" class="flex items-center py-0.5">
<input
type="checkbox"
:id="'conn-' + connection.id"
:value="connection.id"
v-model="selectedConnectionIds"
class="mr-2 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer"
/>
<label :for="'conn-' + connection.id" class="text-sm text-foreground select-none cursor-pointer truncate" :title="connection.name">{{ connection.name }}</label>
</li>
</ul>
</div>
</div>
</div>
<!-- Items to Send Summary -->
<div class="p-3 border border-border rounded-md bg-muted/30 space-y-1">
<h3 class="text-sm font-semibold text-foreground">{{ t('sendFilesModal.itemsToSendTitle') }}</h3>
<ul v-if="itemsToSend && itemsToSend.length > 0" class="max-h-24 overflow-y-auto space-y-0.5">
<li v-for="item in itemsToSend" :key="item.path" class="text-xs text-text-secondary truncate" :title="item.path">
{{ item.name }}
</li>
</ul>
<p v-else class="text-xs text-text-secondary italic">{{ t('sendFilesModal.noItemsSelected') }}</p>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end items-center pt-4 mt-auto border-t border-border flex-shrink-0 space-x-3">
<button
@click="handleCancel"
class="px-4 py-2 bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-background focus:ring-primary disabled:opacity-50 transition-colors duration-150 ease-in-out"
>
{{ t('sendFilesModal.cancelButton') }}
</button>
<button
@click="handleSend"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-background focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150 ease-in-out"
:disabled="selectedConnectionIds.length === 0 || !targetPath.trim()"
>
{{ t('sendFilesModal.sendButton') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useTagsStore, type TagInfo } from '../stores/tags.store';
import apiClient from '../utils/apiClient';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
interface ItemToSend {
name: string;
path: string;
type: 'file' | 'directory'; // Type is now mandatory
}
interface SourceItem { // As per backend InitiateTransferPayload
name: string;
path: string;
type: 'file' | 'directory';
}
interface GroupedConnection {
tag: TagInfo | null;
connections: ConnectionInfo[];
}
const props = defineProps<{
visible: boolean;
itemsToSend: ItemToSend[];
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
// 'send' emit might become obsolete or change if all logic moves to API call
}>();
const { t } = useI18n();
const connectionsStore = useConnectionsStore();
const tagsStore = useTagsStore();
const uiNotificationsStore = useUiNotificationsStore();
const searchTerm = ref('');
const targetPath = ref('');
const transferMethod = ref<'auto' | 'rsync' | 'scp'>('auto');
const selectedConnectionIds = ref<number[]>([]);
const isLoadingConnections = ref(false);
const isLoadingTags = ref(false);
// Simulate data for itemsToSend for development if not provided
const itemsToSendInternal = computed<ItemToSend[]>(() => {
if (props.itemsToSend && props.itemsToSend.length > 0) {
return props.itemsToSend;
}
return [
{ name: 'file1.txt', path: '/local/file1.txt', type: 'file' },
{ name: 'folderA', path: '/local/folderA', type: 'directory' },
{ name: 'another-item.zip', path: '/local/another-item.zip', type: 'file' }
];
});
onMounted(async () => {
isLoadingConnections.value = true;
isLoadingTags.value = true;
try {
if (connectionsStore.connections.length === 0) {
await connectionsStore.fetchConnections();
}
if (tagsStore.tags.length === 0) {
await tagsStore.fetchTags();
}
} catch (error) {
console.error(t('sendFilesModal.errorFetchingData'), error);
// Optionally, show a user-facing error message
} finally {
isLoadingConnections.value = false;
isLoadingTags.value = false;
}
});
const allConnections = computed(() => connectionsStore.connections);
const allTags = computed(() => tagsStore.tags);
const groupedConnections = computed<GroupedConnection[]>(() => {
const groups: Record<string, GroupedConnection> = {};
const untaggedConnections: ConnectionInfo[] = [];
allConnections.value.forEach(conn => {
const connTagIds = conn.tag_ids || [];
if (connTagIds.length === 0) {
untaggedConnections.push(conn);
} else {
connTagIds.forEach((tagId: number) => {
const tag = allTags.value.find(t => t.id === tagId);
if (tag) {
if (!groups[tag.id]) {
groups[tag.id] = { tag, connections: [] };
}
// Avoid adding duplicate connections to the same group
if (!groups[tag.id].connections.some(c => c.id === conn.id)) {
groups[tag.id].connections.push(conn);
}
} else {
// Connection has a tag ID that doesn't exist in tagsStore, treat as untagged for this modal
// Or handle as an error, or create a "missing tag" group
if (!untaggedConnections.some(c => c.id === conn.id)) {
untaggedConnections.push(conn);
}
}
});
}
});
const sortedGroups = Object.values(groups).sort((a, b) =>
a.tag!.name.localeCompare(b.tag!.name)
);
if (untaggedConnections.length > 0) {
return [...sortedGroups, { tag: null, connections: untaggedConnections }];
}
return sortedGroups;
});
const filteredGroupedConnections = computed<GroupedConnection[]>(() => {
if (!searchTerm.value.trim()) {
return groupedConnections.value;
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
return groupedConnections.value
.map(group => {
const filteredConns = group.connections.filter(conn =>
conn.name.toLowerCase().includes(lowerSearchTerm)
);
return { ...group, connections: filteredConns };
})
.filter(group => group.connections.length > 0);
});
const isTagGroupSelected = (group: GroupedConnection): boolean => {
if (group.connections.length === 0) return false;
return group.connections.every(conn => selectedConnectionIds.value.includes(conn.id));
};
const isTagGroupIndeterminate = (group: GroupedConnection): boolean => {
if (group.connections.length === 0) return false;
const selectedCount = group.connections.filter(conn => selectedConnectionIds.value.includes(conn.id)).length;
return selectedCount > 0 && selectedCount < group.connections.length;
};
const toggleTagGroupSelection = (group: GroupedConnection) => {
const groupConnectionIds = group.connections.map(conn => conn.id);
if (isTagGroupSelected(group)) {
// Deselect all
selectedConnectionIds.value = selectedConnectionIds.value.filter(id => !groupConnectionIds.includes(id));
} else {
// Select all (or add to selection if partially selected)
groupConnectionIds.forEach(id => {
if (!selectedConnectionIds.value.includes(id)) {
selectedConnectionIds.value.push(id);
}
});
}
};
watch(() => props.visible, (newValue) => {
if (newValue) {
// Reset state when modal becomes visible, except perhaps targetPath if desired
// searchTerm.value = '';
// selectedConnectionIds.value = [];
// transferMethod.value = 'auto';
// If stores might be empty, fetch again or ensure they are fresh
if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections().catch(error => console.error(t('sendFilesModal.errorFetchingConnections'), error));
}
if (tagsStore.tags.length === 0) {
tagsStore.fetchTags().catch(error => console.error(t('sendFilesModal.errorFetchingTags'), error));
}
}
});
const handleSend = async () => {
if (selectedConnectionIds.value.length === 0 || !targetPath.value.trim()) {
uiNotificationsStore.showError(t('sendFilesModal.validationError')); // Assuming you add this key
return;
}
const sourceItems: SourceItem[] = itemsToSendInternal.value;
const payload = {
sourceItems,
connectionIds: [...selectedConnectionIds.value],
remoteTargetPath: targetPath.value.trim(),
transferMethod: transferMethod.value,
};
try {
const response = await apiClient.post('/transfers/send', payload);
// Assuming the backend returns something like { taskId: "some-id" } on success
if (response.data && response.data.taskId) {
uiNotificationsStore.showSuccess(t('sendFilesModal.transferInitiated', { taskId: response.data.taskId }));
} else {
uiNotificationsStore.showSuccess(t('sendFilesModal.transferInitiatedGeneric'));
}
emit('update:visible', false);
} catch (error: any) {
console.error('Failed to initiate transfer:', error);
const errorMessage = error.response?.data?.message || error.message || t('sendFilesModal.transferFailedError');
uiNotificationsStore.showError(errorMessage);
// Do not close modal on error
}
};
const handleCancel = () => {
emit('update:visible', false);
};
// Fallback i18n messages are now removed as they are expected to be in the locale JSON files.
</script>
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue';
import TabBarContextMenu from './TabBarContextMenu.vue';
import TransferProgressModal from './TransferProgressModal.vue'; //
import { useSessionStore } from '../stores/session.store';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useLayoutStore, type PaneName } from '../stores/layout.store';
@@ -60,6 +61,7 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
const sessionStore = useSessionStore(); // Session store
const showConnectionListPopup = ref(false); //
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
const showTransferProgressModal = ref(false); //
// + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => {
@@ -457,6 +459,13 @@ onBeforeUnmount(() => {
>
<i :class="[eyeIconClass, 'text-sm']"></i>
</button>
<!-- 新增查看传输进度按钮 -->
<button v-if="!isMobile"
class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
@click="showTransferProgressModal = true"
:title="t('terminalTabBar.showTransferProgressTooltip', '查看传输进度')">
<i class="fas fa-tasks text-sm"></i>
</button>
<!-- +++ 使用 v-if 隐藏移动端的布局按钮 +++ -->
<button v-if="!isMobile" class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
@click="openLayoutConfigurator" :title="t('layout.configure', '配置布局')">
@@ -492,5 +501,7 @@ onBeforeUnmount(() => {
@menu-action="handleContextMenuAction"
@close="closeContextMenu"
/>
<!-- 传输进度模态框 -->
<TransferProgressModal v-model:visible="showTransferProgressModal" />
</div>
</template>
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import apiClient from '../utils/apiClient';
interface Props {
visible: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:visible']);
const { t } = useI18n();
// --- ---
//
interface TransferSubTask {
subTaskId: string;
connectionId: number;
sourceItemName: string;
status: 'queued' | 'connecting' | 'transferring' | 'completed' | 'failed';
progress?: number; // 0-100
message?: string;
transferMethodUsed?: 'rsync' | 'scp';
}
interface TransferTask {
taskId: string;
status: 'queued' | 'in-progress' | 'completed' | 'failed' | 'partially-completed';
createdAt: string | Date;
updatedAt: string | Date;
subTasks: TransferSubTask[];
overallProgress?: number;
}
const transferTasks = ref<TransferTask[]>([]);
const isLoading = ref(false);
const errorLoading = ref<string | null>(null);
const pollingIntervalId = ref<number | null>(null);
const fetchTransferTasks = async () => {
isLoading.value = true;
errorLoading.value = null;
try {
// API /api/v1/transfers/status { data: TransferTask[] }
// API访
const response = await apiClient.get<{ data: TransferTask[] }>('/transfers/status');
transferTasks.value = Array.isArray(response.data.data) ? response.data.data : (Array.isArray(response.data) ? response.data : []);
} catch (error: any) {
console.error("Failed to fetch transfer tasks:", error);
errorLoading.value = error.response?.data?.message || error.message || t('transferProgressModal.error.unknown', '未知错误');
} finally {
isLoading.value = false;
}
};
const getDisplayStatus = (status: string): string => {
const statusKeyMap: Record<string, string> = {
'queued': 'transferProgressModal.status.queued',
'in-progress': 'transferProgressModal.status.inProgress',
'completed': 'transferProgressModal.status.completed',
'failed': 'transferProgressModal.status.failed',
'partially-completed': 'transferProgressModal.status.partiallyCompleted',
'connecting': 'transferProgressModal.status.connecting',
'transferring': 'transferProgressModal.status.transferring',
};
// 退i18n key
const defaultText = status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ');
return t(statusKeyMap[status] || `status.${status}`, defaultText);
};
const formatDate = (dateInput: string | Date): string => {
if (!dateInput) return '';
try {
return new Date(dateInput).toLocaleString(navigator.language, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} catch (e) {
return String(dateInput); // Fallback if date is invalid
}
};
onMounted(() => {
if (props.visible) {
fetchTransferTasks();
if (pollingIntervalId.value === null) {
pollingIntervalId.value = window.setInterval(fetchTransferTasks, 5000);
}
}
});
onUnmounted(() => {
if (pollingIntervalId.value !== null) {
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
});
watch(() => props.visible, (newVisible) => {
// internalVisible.value = newVisible; // watch
if (newVisible) {
fetchTransferTasks(); //
if (pollingIntervalId.value === null) { //
pollingIntervalId.value = window.setInterval(fetchTransferTasks, 5000);
}
} else {
if (pollingIntervalId.value !== null) {
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
}, { immediate: false }); // immediate: false onMounted
// --- ---
const internalVisible = ref(props.visible);
// props.visible internalVisible
watch(() => props.visible, (newVisibleValue) => {
internalVisible.value = newVisibleValue;
}, { immediate: true }); //
// internalVisible emit update:visible
watch(internalVisible, (newVal) => {
if (newVal !== props.visible) {
emit('update:visible', newVal);
}
});
const handleClose = () => {
internalVisible.value = false;
};
</script>
<template>
<div
v-if="internalVisible"
class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4"
@click.self="handleClose"
>
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-3xl max-h-[85vh] flex flex-col">
<!-- Header -->
<h3 class="text-xl font-semibold text-center mb-6 flex-shrink-0">
{{ t('transferProgressModal.title', '文件传输进度') }}
</h3>
<!-- Content Area -->
<div class="flex-grow overflow-y-auto mb-6 pr-2 space-y-4 custom-scrollbar">
<div v-if="isLoading && transferTasks.length === 0" class="text-center text-text-secondary py-10">
<svg class="animate-spin h-8 w-8 text-primary mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('transferProgressModal.loading', '正在加载传输任务...') }}
</div>
<div v-else-if="errorLoading" class="text-center text-red-500 bg-red-50 p-4 rounded-md">
<p class="font-semibold">{{ t('transferProgressModal.errorLoadingTitle', '加载错误') }}</p>
<p>{{ t('transferProgressModal.errorLoading', { error: errorLoading }) }}</p>
</div>
<div v-else-if="!isLoading && transferTasks.length === 0" class="text-center text-text-secondary py-10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('transferProgressModal.noTasks', '当前没有活动的传输任务。') }}
</div>
<div v-else class="space-y-3">
<div v-for="task in transferTasks" :key="task.taskId" class="bg-background-alt p-3 rounded-lg border border-border-alt shadow-sm hover:shadow-md transition-shadow">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-semibold text-md block">{{ t('transferProgressModal.task.idLabel', '任务') }}: {{ task.taskId }}</span>
<span class="text-xs text-text-muted">{{ t('transferProgressModal.task.createdAt', '创建于') }}: {{ formatDate(task.createdAt) }}</span>
</div>
<span :class="['px-2.5 py-1 text-xs font-semibold rounded-full',
{ 'bg-green-100 text-green-700': task.status === 'completed' },
{ 'bg-red-100 text-red-700': task.status === 'failed' },
{ 'bg-yellow-100 text-yellow-700': task.status === 'partially-completed' || task.status === 'queued' },
{ 'bg-blue-100 text-blue-700': task.status === 'in-progress' }
]">
{{ getDisplayStatus(task.status) }}
</span>
</div>
<div v-if="task.overallProgress !== undefined" class="mb-2">
<div class="flex justify-between text-xs text-text-secondary mb-0.5">
<span>{{ t('transferProgressModal.task.overallProgress', '整体进度') }}</span>
<span>{{ task.overallProgress }}%</span>
</div>
<div class="w-full bg-border rounded-full h-1.5">
<div class="bg-primary h-1.5 rounded-full" :style="{ width: task.overallProgress + '%' }"></div>
</div>
</div>
<details v-if="task.subTasks && task.subTasks.length > 0" class="mt-2 group">
<summary class="text-xs font-medium text-primary hover:underline cursor-pointer list-none">
{{ t('transferProgressModal.subTasks.titleToggle', { count: task.subTasks.length }) }}
<span class="group-open:hidden">+</span><span class="hidden group-open:inline">-</span>
</summary>
<ul class="mt-2 space-y-1.5 pl-3 border-l border-border-alt ml-1">
<li v-for="subTask in task.subTasks" :key="subTask.subTaskId" class="text-xs p-1.5 rounded bg-background border border-border-alt/50">
<p><strong>{{ t('transferProgressModal.subTask.source', '源文件') }}:</strong> {{ subTask.sourceItemName }}</p>
<p><strong>{{ t('transferProgressModal.subTask.connectionId', '目标连接') }}:</strong> {{ subTask.connectionId }}</p>
<p><strong>{{ t('transferProgressModal.subTask.status', '状态') }}:</strong> {{ getDisplayStatus(subTask.status) }}
<span v-if="subTask.progress !== undefined"> ({{ subTask.progress }}%)</span>
</p>
<p v-if="subTask.transferMethodUsed"><strong>{{ t('transferProgressModal.subTask.method', '方法') }}:</strong> {{ subTask.transferMethodUsed }}</p>
<p v-if="subTask.status === 'failed' && subTask.message" class="text-red-600">
<strong>{{ t('transferProgressModal.subTask.error', '错误') }}:</strong> {{ subTask.message }}
</p>
</li>
</ul>
</details>
<div v-else-if="task.subTasks && task.subTasks.length === 0" class="mt-2 text-xs text-text-muted">
{{ t('transferProgressModal.subTasks.noSubTasks', '没有子任务。') }}
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end items-center pt-4 mt-auto flex-shrink-0 border-t border-border">
<button
@click="handleClose"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out"
>
{{ t('common.close', '关闭') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.bg-overlay {
background-color: rgba(0, 0, 0, 0.6); /* Slightly darker overlay */
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(128, 128, 128, 0.3);
border-radius: 10px;
border: 2px solid transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(128, 128, 128, 0.5);
}
/* For Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
</style>
+23
View File
@@ -1424,5 +1424,28 @@
"logExportSuccess": "Suspended session log {name} has started downloading.",
"logExportError": "Failed to export suspended session log: {error}"
}
},
"sendFilesModal": {
"title": "Send Files",
"searchConnectionsPlaceholder": "Search connections...",
"targetPathLabel": "Target Path",
"targetPathPlaceholder": "/remote/path/to/destination",
"transferMethodLabel": "Transfer Method",
"transferMethodAuto": "Auto",
"loadingConnections": "Loading connections...",
"noConnections": "No connections available. Please add connections first.",
"noConnectionsFound": "No connections found matching your search.",
"untaggedConnections": "Untagged",
"itemsToSendTitle": "Items to send:",
"noItemsSelected": "No items selected to send.",
"sendButton": "Send",
"cancelButton": "Cancel",
"errorFetchingData": "Error fetching data for modal.",
"errorFetchingConnections": "Error fetching connections.",
"errorFetchingTags": "Error fetching tags.",
"validationError": "Please select at least one connection and specify a target path.",
"transferInitiated": "Transfer task created, Task ID: ",
"transferInitiatedGeneric": "Transfer task created successfully.",
"transferFailedError": "Failed to initiate transfer. Please try again."
}
}
+23
View File
@@ -1385,5 +1385,28 @@
"resumeErrorBackend": "バックエンドがセッションの再開に失敗しました: {error}",
"autoTerminated": "中断されたセッション「{name}」は、理由: {reason} によりバックエンドによって自動終了されました。"
}
},
"sendFilesModal": {
"title": "ファイル送信",
"searchConnectionsPlaceholder": "接続を検索...",
"targetPathLabel": "ターゲットパス",
"targetPathPlaceholder": "/remote/path/to/destination",
"transferMethodLabel": "転送方法",
"transferMethodAuto": "自動",
"loadingConnections": "接続を読み込み中...",
"noConnections": "利用可能な接続がありません。まず接続を追加してください。",
"noConnectionsFound": "検索に一致する接続が見つかりませんでした。",
"untaggedConnections": "タグなし",
"itemsToSendTitle": "送信するアイテム:",
"noItemsSelected": "送信するアイテムが選択されていません。",
"sendButton": "送信",
"cancelButton": "キャンセル",
"errorFetchingData": "モーダルデータの取得中にエラーが発生しました。",
"errorFetchingConnections": "接続データの取得中にエラーが発生しました。",
"errorFetchingTags": "タグデータの取得中にエラーが発生しました。",
"validationError": "少なくとも1つの接続を選択し、ターゲットパスを指定してください。",
"transferInitiated": "転送タスクが作成されました、タスクID: ",
"transferInitiatedGeneric": "転送タスクが正常に作成されました。",
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
}
}
+59
View File
@@ -1396,6 +1396,65 @@
"remove": "移除",
"exportLog": "导出日志"
}
},
"transferProgressModal": {
"title": "文件传输进度",
"loading": "正在加载传输任务...",
"errorLoadingTitle": "加载错误",
"errorLoading": "加载传输任务时出错: {error}",
"noTasks": "当前没有活动的传输任务。",
"error": {
"unknown": "发生未知错误"
},
"status": {
"queued": "排队中",
"inProgress": "进行中",
"completed": "已完成",
"failed": "已失败",
"partiallyCompleted": "部分完成",
"connecting": "连接中",
"transferring": "传输中"
},
"task": {
"idLabel": "任务",
"createdAt": "创建于",
"overallProgress": "整体进度"
},
"subTasks": {
"titleToggle": "查看/隐藏 {count} 个子任务",
"noSubTasks": "没有子任务。"
},
"subTask": {
"source": "源文件",
"connectionId": "目标连接",
"status": "状态",
"method": "方法",
"error": "错误"
}
},
"sendFilesModal": {
"title": "发送文件",
"searchConnectionsPlaceholder": "搜索连接...",
"targetPathLabel": "目标路径",
"targetPathPlaceholder": "/远程/路径/到/目的地",
"transferMethodLabel": "传输方式",
"transferMethodAuto": "自动",
"loadingConnections": "正在加载连接...",
"noConnections": "没有可用的连接。请先添加连接。",
"noConnectionsFound": "未找到匹配搜索的连接。",
"untaggedConnections": "未标记",
"itemsToSendTitle": "待发送项目:",
"noItemsSelected": "未选择待发送的项目。",
"sendButton": "发送",
"cancelButton": "取消",
"errorFetchingData": "获取模态框数据时出错。",
"errorFetchingConnections": "获取连接数据时出错。",
"errorFetchingTags": "获取标签数据时出错。",
"validationError": "请至少选择一个连接并指定目标路径。",
"transferInitiated": "传输任务已创建,任务 ID: ",
"transferInitiatedGeneric": "传输任务创建成功。",
"transferFailedError": "发起传输失败。请重试。"
},
"time": {
"unknown": "未知时间",