Files
nexus-terminal/packages/frontend/src/components/SendFilesModal.vue
T
2025-05-16 23:34:14 +08:00

434 lines
18 KiB
Vue

<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="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;"
>
<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-4 max-h-72 overflow-y-auto bg-header/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="getGroupId(group)" class="tag-group">
<div
class="flex items-center py-1.5 cursor-pointer group"
@click="toggleTagGroupExpansion(group)"
>
<input
type="checkbox"
:id="'tag-cb-' + getGroupId(group)"
:checked="isTagGroupSelected(group)"
:indeterminate="isTagGroupIndeterminate(group)"
@change="toggleTagGroupSelection(group)"
@click.stop
class="mr-1.5 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer"
/>
<i
:class="['fas', expandedTagGroups[getGroupId(group)] ?? true ? 'fa-chevron-down' : 'fa-chevron-right', 'mr-2 w-3 text-center text-text-secondary/80 group-hover:text-text-secondary transition-transform duration-150 ease-in-out']"
style="font-size: 0.75rem;"
></i>
<label
:for="'tag-cb-' + getGroupId(group)"
class="font-semibold text-foreground select-none cursor-pointer text-sm"
@click.stop
>
{{ group.tag ? group.tag.name : t('sendFilesModal.untaggedConnections') }} ({{ group.connections.length }})
</label>
</div>
<ul v-show="expandedTagGroups[getGroupId(group)] ?? true" class="pl-7 space-y-0.5">
<li
v-for="connection in group.connections"
:key="connection.id"
class="flex items-center p-2.5 rounded-md hover:bg-primary/10 cursor-pointer transition-colors duration-150"
:class="{'bg-primary/20': selectedConnectionIds.includes(connection.id)}"
@click="toggleIndividualConnectionSelection(connection.id)"
>
<input
type="checkbox"
:id="'conn-' + connection.id"
:value="connection.id"
v-model="selectedConnectionIds"
class="mr-3 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0"
@click.stop
/>
<i :class="getConnectionIconClass(connection.type) + ' mr-2.5 w-4 text-center text-text-secondary'"></i>
<span class="text-sm truncate flex-grow" :title="connection.name">{{ connection.name }}</span>
</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';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 导入事件发射器 +++
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[];
sourceConnectionId: number | null; // +++ 新增 sourceConnectionId prop +++
}>();
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 emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ 获取事件发射器实例 +++
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);
const expandedTagGroups = ref<Record<string, boolean>>({});
const getGroupId = (group: GroupedConnection): string => {
return group.tag ? String(group.tag.id) : 'untagged';
};
const toggleTagGroupExpansion = (group: GroupedConnection) => {
const groupId = getGroupId(group);
expandedTagGroups.value[groupId] = !(expandedTagGroups.value[groupId] ?? true);
};
// 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 => {
if (conn.type?.toLowerCase() !== 'ssh') { // 首先过滤掉非 SSH 连接
return;
}
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 {
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[]>(() => {
const baseGroups = groupedConnections.value;
if (!searchTerm.value.trim()) {
// If no search term, filter out groups that initially have no connections.
return baseGroups.filter(group => group.connections.length > 0);
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
const result = baseGroups
.map(group => {
const groupDisplayName = group.tag ? group.tag.name : t('sendFilesModal.untaggedConnections');
const isTagMatch = groupDisplayName.toLowerCase().includes(lowerSearchTerm);
const connsMatchingSearchByName = group.connections.filter(conn =>
conn.name.toLowerCase().includes(lowerSearchTerm)
// conn.type filtering is already handled in groupedConnections
);
if (isTagMatch) {
// Tag name matches. Show all connections of this group.
return { ...group, connections: group.connections };
} else if (connsMatchingSearchByName.length > 0) {
// Tag name doesn't match, but some connection names do. Show only those connections.
return { ...group, connections: connsMatchingSearchByName };
}
return null; // Group doesn't match by tag name and has no connections matching by name
})
.filter(group => group !== null && group.connections.length > 0) as GroupedConnection[];
return result;
});
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) {
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 = {
sourceConnectionId: props.sourceConnectionId, // +++ 添加 sourceConnectionId 到 payload +++
connectionIds: [...selectedConnectionIds.value], // 这些是目标服务器IDs
sourceItems,
remoteTargetPath: targetPath.value.trim(),
transferMethod: transferMethod.value,
};
// 验证 sourceConnectionId 是否存在
if (payload.sourceConnectionId === null || payload.sourceConnectionId === undefined) {
console.error('Source Connection ID is missing in SendFilesModal payload:', payload);
uiNotificationsStore.showError(t('sendFilesModal.errorSourceConnectionMissing', 'Source server information is missing. Cannot initiate transfer.'));
return;
}
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'));
}
emitWorkspaceEvent('ui:openTransferProgressModal'); // +++ 触发打开传输进度模态框的事件 +++
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);
};
const toggleIndividualConnectionSelection = (connectionId: number) => {
const index = selectedConnectionIds.value.indexOf(connectionId);
if (index > -1) {
selectedConnectionIds.value.splice(index, 1);
} else {
selectedConnectionIds.value.push(connectionId);
}
};
const getConnectionIconClass = (connectionType?: string): string => {
const type = connectionType?.toLowerCase();
switch (type) {
case 'rdp': return 'fas fa-desktop';
case 'vnc': return 'fas fa-plug';
case 'ssh': return 'fas fa-server';
case 'telnet': return 'fas fa-keyboard';
case 'local': return 'fas fa-laptop';
case 'serial': return 'fas fa-microchip';
case 'docker': return 'fab fa-docker';
default: return 'fas fa-server';
}
};
</script>