feat: 添加标签管理模态框

This commit is contained in:
Baobhan Sith
2025-05-11 11:20:26 +08:00
parent 1eb1efde72
commit 598df938bf
40 changed files with 634 additions and 170 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ const route = useRoute();
const navRef = ref<HTMLElement | null>(null);
const underlineRef = ref<HTMLElement | null>(null);
// +++ 新增:存储上一次由切换器聚焦的 ID +++
// +++ 存储上一次由切换器聚焦的 ID +++
const lastFocusedIdBySwitcher = ref<string | null>(null);
const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
const altShortcutKey = ref<string | null>(null);
@@ -679,7 +679,7 @@ const testButtonText = computed(() => {
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
<div class="flex space-x-3"> <!-- Main Actions -->
<button v-if="isEditMode" type="button" @click="handleDeleteConnection" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
class="px-4 py-2 bg-transparent text-red-600 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
class="px-4 py-2 bg-transparent text-red-600 border border-red-500 rounded-md shadow-sm hover:bg-red-500/10 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
{{ t('connections.actions.delete') }}
</button>
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
@@ -9,7 +9,7 @@ import { useQuickCommandsStore } from '../stores/quickCommands.store';
import { useCommandHistoryStore } from '../stores/commandHistory.store';
import QuickCommandsModal from './QuickCommandsModal.vue'; // +++ Import the modal component +++
import SuspendedSshSessionsModal from './SuspendedSshSessionsModal.vue'; // +++ Import the new modal +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
// Disable attribute inheritance as this component has multiple root nodes (div + modal)
defineOptions({ inheritAttrs: false });
@@ -10,7 +10,7 @@ import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++
import { useSessionStore } from '../stores/session.store'; // +++ 导入会话 Store +++
import { useSettingsStore } from '../stores/settings.store'; // +++ 导入设置 Store +++
import { storeToRefs } from 'pinia'; // +++ 导入 storeToRefs +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
const { t } = useI18n();
const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ 获取事件发射器 +++
@@ -120,13 +120,11 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态
// +++ 新增:计算当前选择的编码 +++
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
// +++ 新增:计算当前活动标签的会话名称 +++
const currentTabSessionName = computed(() => {
const sessionId = activeTab.value?.sessionId;
if (!sessionId) return null;
return sessionStore.sessions.get(sessionId)?.connectionName ?? null; // 修正:使用 connectionName
return sessionStore.sessions.get(sessionId)?.connectionName ?? null;
});
// Watch for changes in the selected encoding to update width
@@ -134,7 +132,7 @@ watch(currentSelectedEncoding, () => {
updateSelectWidth();
});
// +++ 新增:编码选项 +++
// +++ 编码选项 +++
// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用)
// 扩展编码列表以包含更多常用选项
const encodingOptions = ref([
@@ -194,7 +192,7 @@ const handleSaveRequest = () => {
}
};
// +++ 新增:处理编码更改事件 +++
// +++ 处理编码更改事件 +++
const handleEncodingChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const newEncoding = target.value;
@@ -243,7 +241,7 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
// +++ 新增:处理键盘事件以切换标签 +++
// +++ 处理键盘事件以切换标签 +++
const handleKeyDown = (event: KeyboardEvent) => {
// 检查是否在编辑器内部或其容器内触发(避免全局冲突)
// 这里简化处理,假设只要此组件挂载就监听,更精确的判断可能需要检查 event.target
@@ -201,9 +201,9 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false);
// +++ ( Container ) +++
// +++ ( Container ) +++
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
// +++ ( Container ) +++
// +++ ( Container ) +++
const currentTabSessionName = computed(() => {
const sessionId = activeTab.value?.sessionId;
if (!sessionId) return null;
@@ -213,7 +213,7 @@ const currentTabSessionName = computed(() => {
// --- ( action) ---
// +++ (copied from FileEditorContainer) +++
// +++ (copied from FileEditorContainer) +++
// value iconv-lite (使)
const encodingOptions = ref([
// Unicode
@@ -352,7 +352,7 @@ const handleCloseLeftTabs = (targetTabId: string) => {
}
};
// +++ +++
// +++ +++
const handleEncodingChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const newEncoding = target.value;
@@ -488,7 +488,7 @@ onBeforeUnmount(() => {
<span v-if="currentTabIsModified" class="modified-indicator">*</span>
</span>
<div class="editor-actions">
<!-- +++ 新增编码选择下拉菜单 +++ -->
<!-- +++ 编码选择下拉菜单 +++ -->
<div class="encoding-select-wrapper" v-if="activeTab && !currentTabIsLoading">
<select
ref="encodingSelectRef"
@@ -725,7 +725,7 @@ onBeforeUnmount(() => {
}
*/
/* +++ 新增:编码选择器样式 (copied from FileEditorContainer) +++ */
/* +++ 编码选择器样式 (copied from FileEditorContainer) +++ */
.encoding-select-wrapper {
display: inline-block; /* 让 wrapper 包裹内容 */
vertical-align: middle; /* 垂直居中对齐 */
@@ -15,7 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import FileUploadPopup from './FileUploadPopup.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue'; // +++ +++
import FileManagerActionModal from './FileManagerActionModal.vue';
import type { FileListItem } from '../types/sftp.types';
import type { WebSocketMessage } from '../types/websocket.types';
@@ -29,7 +29,7 @@ const props = defineProps({
type: String,
required: true,
},
// ID
// ID
instanceId: {
type: String,
required: true,
@@ -117,28 +117,28 @@ const sortDirection = ref<'asc' | 'desc'>('asc');
// const initialLoadDone = ref(false); // SFTP Manager
// const isFetchingInitialPath = ref(false); // isLoading !initialLoadDone
const isEditingPath = ref(false);
const searchQuery = ref(''); // ref
const isSearchActive = ref(false); //
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const searchQuery = ref(''); // ref
const isSearchActive = ref(false); //
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const pathInputRef = ref<HTMLInputElement | null>(null);
const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); //
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ +++
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ +++
// const scrollIntervalId = ref<number | null>(null); // useFileManagerDragAndDrop
// +++ +++
// +++ +++
const isActionModalVisible = ref(false);
const currentActionType = ref<'delete' | 'rename' | 'chmod' | 'newFile' | 'newFolder' | null>(null);
const actionItem = ref<FileListItem | null>(null); // For single item operations
const actionItems = ref<FileListItem[]>([]); // For multi-item operations (e.g., delete)
const actionInitialValue = ref(''); // For pre-filling input in modal
// +++ +++
// +++ +++
const clipboardState = ref<ClipboardState>({ hasContent: false });
const clipboardSourcePaths = ref<string[]>([]); //
const clipboardSourceBaseDir = ref<string>(''); //
const rowSizeMultiplier = ref(1.0); // , store
const rowSizeMultiplier = ref(1.0); // , store
// --- ( useFileManagerKeyboardNavigation) ---
// const selectedIndex = ref<number>(-1);
@@ -416,7 +416,7 @@ const handleNewFileContextMenuClick = () => {
openActionModal('newFile');
};
// +++ +++
// +++ +++
const handleCopy = () => {
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
const manager = currentSftpManager.value;
@@ -526,7 +526,7 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
};
// +++ +++
// +++ +++
const triggerDownloadDirectory = (item: FileListItem) => {
if (!props.wsDeps.isConnected.value) {
alert(t('fileManager.errors.notConnected'));
@@ -659,7 +659,7 @@ const {
// --- (使 Composable) ---
const {
// isDraggingOver, // 使
showExternalDropOverlay, //
showExternalDropOverlay, //
dragOverTarget, // ()
// draggedItem, // FileManager 使
// --- ---
@@ -667,7 +667,7 @@ const {
handleDragOver, // dragover ()
handleDragLeave,
handleDrop, // drop ()
handleOverlayDrop, // drop
handleOverlayDrop, // drop
handleDragStart,
handleDragEnd,
handleDragOverRow,
@@ -899,7 +899,7 @@ watch(() => focusSwitcherStore.activateFileManagerSearchTrigger, (newValue, oldV
}, { immediate: false }); // immediate: false 0
// --- sessionId prop ---
// --- sessionId prop ---
watch(() => props.sessionId, (newSessionId, oldSessionId) => {
if (newSessionId && newSessionId !== oldSessionId) {
console.log(`[FileManager ${newSessionId}-${props.instanceId}] Session ID changed from ${oldSessionId} to ${newSessionId}. Re-initializing.`);
@@ -981,7 +981,7 @@ onBeforeUnmount(() => {
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
});
// +++ +++
// +++ +++
watch(showExternalDropOverlay, (isVisible) => {
if (isVisible) {
nextTick(() => { // refs scrollHeight
@@ -1112,7 +1112,7 @@ const cancelSearch = () => {
isSearchActive.value = false;
};
// --- CD ---
// --- CD ---
const sendCdCommandToTerminal = () => {
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: SFTP manager not ready or not connected.`);
@@ -1160,7 +1160,7 @@ const sendCdCommandToTerminal = () => {
};
// --- ---
// --- ---
const openPopupEditor = () => {
if (!props.sessionId) {
console.error('[FileManager] Cannot open popup editor: Missing session ID.');
@@ -1189,7 +1189,7 @@ const handleWheel = (event: WheelEvent) => {
}
};
// +++ +++
// +++ +++
const focusSearchInput = (): boolean => {
//
if (props.sessionId !== sessionStore.activeSessionId) {
@@ -1219,7 +1219,7 @@ const focusSearchInput = (): boolean => {
};
defineExpose({ focusSearchInput, startPathEdit });
// --- ---
// --- ---
const handleOpenEditorClick = () => {
if (!props.sessionId) {
console.error(`[FileManager ${props.instanceId}] Cannot open editor: Missing session ID.`);
@@ -1240,7 +1240,7 @@ const handleOpenEditorClick = () => {
<div class="flex items-center gap-2 flex-shrink"> <!-- Added gap-2 -->
<!-- Path Actions -->
<div class="flex items-center flex-shrink-0"> <!-- Removed mr-auto -->
<!-- 新增CD 到终端按钮 -->
<!-- CD 到终端按钮 -->
<button
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="sendCdCommandToTerminal"
@@ -1329,7 +1329,7 @@ const handleOpenEditorClick = () => {
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<!-- 新增打开编辑器按钮 -->
<!-- 打开编辑器按钮 -->
<button
v-if="showPopupFileEditorBoolean"
@click="openPopupEditor"
@@ -1387,7 +1387,7 @@ const handleOpenEditorClick = () => {
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<!-- 新增外部文件拖拽蒙版 -->
<!-- 外部文件拖拽蒙版 -->
<div
v-if="showExternalDropOverlay"
ref="dropOverlayRef"
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useTagsStore, type TagInfo } from '../stores/tags.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
interface Props {
tagInfo: TagInfo | null; // TagInfo
visible: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:visible', 'saved', 'tag-deleted']);
const { t } = useI18n();
const connectionsStore = useConnectionsStore();
const tagsStore = useTagsStore(); // actions
const uiNotificationsStore = useUiNotificationsStore();
const { connections: allConnections, isLoading: connectionsLoading } = storeToRefs(connectionsStore);
const modalSearchTerm = ref('');
// 使 Set ID便
const selectedConnectionIds = ref<Set<number>>(new Set());
const internalVisible = ref(props.visible);
// props.visible internalVisible
watch(() => props.visible, (newVisibleValue) => {
internalVisible.value = newVisibleValue;
if (!newVisibleValue) {
//
modalSearchTerm.value = '';
}
});
// internalVisible emit update:visible
watch(internalVisible, (newVal) => {
if (newVal !== props.visible) { //
emit('update:visible', newVal);
}
});
// tagInfo
watch(() => [internalVisible.value, props.tagInfo], ([isVisible, currentTagInfoUntyped]) => {
const currentTagInfo = currentTagInfoUntyped as TagInfo | null; //
if (isVisible && currentTagInfo && typeof currentTagInfo === 'object' && currentTagInfo !== null && 'id' in currentTagInfo) {
selectedConnectionIds.value.clear();
allConnections.value.forEach(conn => {
if (conn.tag_ids?.includes(currentTagInfo.id)) {
selectedConnectionIds.value.add(conn.id);
}
});
}
}, { immediate: true, deep: true });
const filteredConnectionsInModal = computed(() => {
if (!modalSearchTerm.value) {
return allConnections.value.slice().sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
}
const lowerSearchTerm = modalSearchTerm.value.toLowerCase();
return allConnections.value
.filter(conn =>
(conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) ||
conn.host.toLowerCase().includes(lowerSearchTerm)
)
.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
});
const isConnectionSelected = (connectionId: number): boolean => {
return selectedConnectionIds.value.has(connectionId);
};
const toggleConnectionSelection = (connectionId: number) => {
if (selectedConnectionIds.value.has(connectionId)) {
selectedConnectionIds.value.delete(connectionId);
} else {
selectedConnectionIds.value.add(connectionId);
}
};
const handleSelectAll = () => {
filteredConnectionsInModal.value.forEach(conn => selectedConnectionIds.value.add(conn.id));
};
const handleDeselectAll = () => {
filteredConnectionsInModal.value.forEach(conn => selectedConnectionIds.value.delete(conn.id));
};
const handleInvertSelection = () => {
filteredConnectionsInModal.value.forEach(conn => {
if (selectedConnectionIds.value.has(conn.id)) {
selectedConnectionIds.value.delete(conn.id);
} else {
selectedConnectionIds.value.add(conn.id);
}
});
};
const handleSave = async () => {
if (!props.tagInfo) return;
const currentTagId = props.tagInfo.id;
const newConnectionIdArray = Array.from(selectedConnectionIds.value);
// store action
// tagsStore updateTagConnections action
const success = await tagsStore.updateTagConnections(currentTagId, newConnectionIdArray);
if (success) {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.manageTags.saveSuccess'), type: 'success' });
emit('saved'); //
emit('update:visible', false);
} else {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.manageTags.saveFailed'), type: 'error' });
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const handleDeleteTag = async () => {
if (!props.tagInfo) return;
const tagName = props.tagInfo.name;
if (confirm(t('tags.prompts.confirmDelete', { name: tagName }))) {
const success = await tagsStore.deleteTag(props.tagInfo.id);
if (success) {
uiNotificationsStore.addNotification({ message: t('tags.deleteSuccess', { name: tagName }), type: 'success' }); //
emit('tag-deleted'); //
emit('update:visible', false); //
} else {
uiNotificationsStore.addNotification({ message: t('tags.deleteFailed', { name: tagName, error: tagsStore.error || 'Unknown error' }), type: 'error' }); //
}
}
};
onMounted(() => {
if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections();
}
});
</script>
<template>
<div
v-if="internalVisible"
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 -->
<h3 class="text-xl font-semibold text-center mb-6 flex-shrink-0">
{{ t('workspaceConnectionList.manageTags.title') }} - {{ props.tagInfo?.name }}
</h3>
<!-- Controls & List Container -->
<div class="flex-grow overflow-y-hidden flex flex-col">
<!-- Controls -->
<div class="p-4 border-b border-border/50 flex items-center gap-2 flex-shrink-0">
<input
type="text"
v-model="modalSearchTerm"
:placeholder="t('workspaceConnectionList.manageTags.searchPlaceholder')"
class="flex-grow min-w-0 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"
/>
<button
@click="handleSelectAll"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.selectAll') }}
</button>
<button
@click="handleDeselectAll"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.deselectAll') }}
</button>
<button
@click="handleInvertSelection"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.invertSelection') }}
</button>
</div>
<!-- Connection List -->
<div class="flex-grow overflow-y-auto p-4 pr-2"> <!-- Removed space-y-2 from here -->
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30"> <!-- New wrapper div -->
<div v-if="connectionsLoading" class="flex items-center justify-center h-full text-text-secondary">
<i class="fas fa-spinner fa-spin mr-2"></i> {{ t('common.loading') }}
</div>
<ul v-else-if="filteredConnectionsInModal.length > 0" class="space-y-1">
<li
v-for="conn in filteredConnectionsInModal"
:key="conn.id"
class="flex items-center p-2.5 rounded-md hover:bg-primary/10 cursor-pointer transition-colors duration-150"
:class="{'bg-primary/20': isConnectionSelected(conn.id)}"
@click="toggleConnectionSelection(conn.id)"
>
<input
type="checkbox"
:checked="isConnectionSelected(conn.id)"
class="mr-3 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0"
@change.stop="toggleConnectionSelection(conn.id)"
@click.stop
/>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-plug' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary']"></i>
<span class="text-sm truncate flex-grow" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
<span class="text-xs text-text-tertiary ml-2">({{ conn.type }})</span>
</li>
</ul>
<div v-else class="flex flex-col items-center justify-center h-full text-text-secondary p-6">
<i class="fas fa-search text-2xl mb-3"></i>
<p>{{ t('workspaceConnectionList.manageTags.noConnectionsFound') }}</p>
</div>
</div> <!-- End of new wrapper div -->
</div>
</div>
<!-- Footer -->
<div class="flex justify-end items-center pt-5 mt-auto flex-shrink-0 space-x-3">
<button
@click="handleDeleteTag"
class="px-4 py-2 bg-transparent text-error border border-error/70 rounded-md shadow-sm hover:bg-error/10 hover:text-error-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error transition duration-150 ease-in-out"
>
{{ t('common.delete') }}
</button>
<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-2 focus:ring-primary transition duration-150 ease-in-out"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleSave"
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.save') }}
</button>
</div>
</div>
</div>
</template>
@@ -8,13 +8,13 @@ import { FitAddon } from '@xterm/addon-fit'; // Updated import path
import { WebLinksAddon } from 'xterm-addon-web-links';
import { SearchAddon, type ISearchOptions } from '@xterm/addon-search';
import 'xterm/css/xterm.css';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
// props emits
const props = defineProps<{
sessionId: string; // ID
isActive: boolean; //
isActive: boolean; //
stream?: ReadableStream<string>; // WebSocket ()
options?: object; // xterm
}>();
@@ -39,7 +39,7 @@ const {
currentTerminalFontFamily,
terminalBackgroundImage,
currentTerminalFontSize,
isTerminalBackgroundEnabled // <--
isTerminalBackgroundEnabled
} = storeToRefs(appearanceStore);
// --- Settings Store ---
@@ -69,7 +69,7 @@ const debouncedEmitResize = debounce((term: Terminal) => {
const dimensions = { cols: term.cols, rows: term.rows };
console.log(`[Terminal ${props.sessionId}] Debounced resize emit (from ResizeObserver):`, dimensions);
emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: dimensions });
// *** resize ***
// *** resize ***
try {
term.refresh(0, term.rows - 1); // Refresh entire viewport
console.log(`[Terminal ${props.sessionId}] Terminal refreshed after debounced resize.`);
@@ -9,7 +9,7 @@ import TabBarContextMenu from './TabBarContextMenu.vue';
import { useSessionStore } from '../stores/session.store';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useLayoutStore, type PaneName } from '../stores/layout.store';
import { useWorkspaceEventEmitter, useWorkspaceEventSubscriber } from '../composables/workspaceEvents'; // +++ +++
import { useWorkspaceEventEmitter, useWorkspaceEventSubscriber } from '../composables/workspaceEvents';
import type { SessionTabInfoWithStatus } from '../stores/session/types'; //
@@ -99,14 +99,14 @@ const handlePopupConnect = (connectionId: number) => {
showConnectionListPopup.value = false; //
};
//
//
const handleRequestAddFromPopup = () => {
console.log('[TabBar] Received request-add-connection from popup component.');
showConnectionListPopup.value = false; //
emitWorkspaceEvent('connection:requestAdd'); //
};
//
//
const handleRequestEditFromPopup = (connection: ConnectionInfo) => { // WorkspaceConnectionList
console.log('[TabBar] Received request-edit-connection from popup component for connection:', connection);
showConnectionListPopup.value = false; //
@@ -180,7 +180,7 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
console.warn(`[TabBar] 'mark-for-suspend' action called with invalid targetId:`, targetId);
}
break;
case 'unmark-for-suspend': // +++ case +++
case 'unmark-for-suspend':
if (typeof targetId === 'string') {
console.log(`[TabBar] Context menu action 'unmark-for-suspend' requested for session ID: ${targetId}`);
sessionStore.requestUnmarkSshSuspend(targetId);
@@ -250,7 +250,7 @@ const contextMenuItems = computed(() => {
});
//
//
const openLayoutConfigurator = () => {
console.log('[TabBar] Emitting open-layout-configurator event');
emitWorkspaceEvent('ui:openLayoutConfigurator'); //
@@ -328,7 +328,7 @@ const handleDragStart = (event: DragEvent) => {
}
};
//
//
let touchTimeout: number | null = null;
const touchDuration = 800; //
let touchedSessionId: string | null = null;
@@ -9,8 +9,9 @@ import { useTagsStore, TagInfo } from '../stores/tags.store'; // 确保 TagInfo
import { useSessionStore } from '../stores/session.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // +++ +++
import { useSettingsStore } from '../stores/settings.store'; // store
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ +++
import { useSettingsStore } from '../stores/settings.store';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import ManageTagConnectionsModal from './ManageTagConnectionsModal.vue';
//
@@ -23,15 +24,15 @@ const tagsStore = useTagsStore();
const sessionStore = useSessionStore(); // session store
const focusSwitcherStore = useFocusSwitcherStore(); // +++ Store +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ +++
const settingsStore = useSettingsStore(); // store
const settingsStore = useSettingsStore(); // store
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); //
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); //
//
const searchTerm = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
//
const contextMenuVisible = ref(false);
@@ -43,6 +44,10 @@ const tagContextMenuVisible = ref(false);
const tagContextMenuPosition = ref({ x: 0, y: 0 });
const contextTargetTagGroup = ref<(typeof filteredAndGroupedConnections.value)[0] | null>(null);
// +++ +++
const showManageTagModal = ref(false);
const tagToManage = ref<TagInfo | null>(null);
// +++ +++
const EXPANDED_GROUPS_STORAGE_KEY = 'workspaceConnectionListExpandedGroups';
@@ -213,7 +218,7 @@ const filteredAndGroupedConnections = computed(() => {
return result;
});
// ( showConnectionTagsBoolean false )
// ( showConnectionTagsBoolean false )
const flatFilteredConnections = computed(() => {
const lowerSearchTerm = searchTerm.value.toLowerCase();
const tagMap = new Map(tags.value.map(tag => [tag.id, tag.name])); // tagMap
@@ -405,8 +410,9 @@ const closeTagContextMenu = () => {
};
//
const handleTagMenuAction = (action: 'connectAll') => {
const group = contextTargetTagGroup.value;
// groupData
const handleTagMenuAction = (action: 'connectAll' | 'manageTag', directGroupData?: (typeof filteredAndGroupedConnections.value)[0]) => {
const group = directGroupData || contextTargetTagGroup.value; // 使 groupData
closeTagContextMenu(); //
if (group && action === 'connectAll') {
@@ -426,9 +432,29 @@ const handleTagMenuAction = (action: 'connectAll') => {
type: 'info',
});
}
} else if (group && action === 'manageTag') {
if (group.tagId !== null) { // ""
tagToManage.value = {
id: group.tagId,
name: group.groupName,
created_at: tags.value.find(t => t.id === group.tagId)?.created_at || Date.now() / 1000, //
updated_at: tags.value.find(t => t.id === group.tagId)?.updated_at || Date.now() / 1000,
};
showManageTagModal.value = true;
} else {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.manageTags.cannotManageUntagged'), //
type: 'warning',
});
}
}
};
const handleManageTagModalSaved = () => {
connectionsStore.fetchConnections(); //
tagsStore.fetchTags(); //
};
//
// handleConnect
setTimeout(() => {
@@ -715,8 +741,17 @@ const cancelEditingTag = () => {
</span>
<!-- 占位符占据剩余空间 -->
<div class="flex-grow min-w-0"></div>
</div>
<!-- Connection Items List -->
<!-- 标签栏右侧的编辑按钮 -->
<button
v-if="groupData.tagId !== null && editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)"
@click.stop="handleTagMenuAction('manageTag', groupData)"
class="ml-2 px-1 h-6 flex items-center justify-center rounded text-text-secondary hover:text-primary hover:bg-black/10 opacity-0 group-hover:opacity-100 transition-all duration-150 focus:outline-none"
:title="t('workspaceConnectionList.manageTags.menuItem')"
>
<i class="fas fa-edit fa-xs"></i>
</button>
</div>
<!-- Connection Items List -->
<ul v-show="expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3">
<!-- ... li v-for="conn in groupData.connections" ... -->
<li
@@ -809,6 +844,15 @@ const cancelEditingTag = () => {
<i class="fas fa-ban mr-3 w-4 text-center text-text-disabled"></i>
<span>{{ t('workspaceConnectionList.noSshConnectionsToConnectMenu') }}</span>
</li>
<li class="my-1 border-t border-border/50" v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"></li>
<li
v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"
class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleTagMenuAction('manageTag')"
>
<i class="fas fa-tags mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('workspaceConnectionList.manageTags.menuItem') }}</span>
</li>
<!-- Future: Add "Rename Tag" or "Delete Tag (if empty)" options here -->
</ul>
</div>
@@ -819,7 +863,12 @@ const cancelEditingTag = () => {
:connection="selectedRdpConnection"
@close="closeRdpModal"
/> -->
</div>
<ManageTagConnectionsModal
:tag-info="tagToManage"
v-model:visible="showManageTagModal"
@saved="handleManageTagModalSaved"
/>
</div>
</template>
<!-- Scoped styles removed, now using Tailwind utility classes -->
@@ -25,21 +25,21 @@ export interface UseFileManagerContextMenuOptions {
currentPath: Ref<string>;
isConnected: Ref<boolean>;
isSftpReady: Ref<boolean>;
clipboardState: Ref<Readonly<ClipboardState>>; // +++ 新增:剪贴板状态 +++
clipboardState: Ref<Readonly<ClipboardState>>; // +++ 剪贴板状态 +++
t: ReturnType<typeof useI18n>['t']; // 使用 useI18n 获取 t 的类型
// --- 回调函数 ---
onRefresh: () => void;
onUpload: () => void;
onDownload: (items: FileListItem[]) => void; // 文件下载回调
onDownloadDirectory: (item: FileListItem) => void; // +++ 新增:文件夹下载回调 +++
onDownloadDirectory: (item: FileListItem) => void; // +++ 文件夹下载回调 +++
onDelete: () => void; // 删除操作现在由外部处理
onRename: (item: FileListItem) => void;
onChangePermissions: (item: FileListItem) => void;
onNewFolder: () => void;
onNewFile: () => void;
onCopy: () => void; // +++ 新增:复制回调 +++
onCut: () => void; // +++ 新增:剪切回调 +++
onPaste: () => void; // +++ 新增:粘贴回调 +++
onCopy: () => void; // +++ 复制回调 +++
onCut: () => void; // +++ 剪切回调 +++
onPaste: () => void; // +++ 粘贴回调 +++
}
export function useFileManagerContextMenu(options: UseFileManagerContextMenuOptions) {
@@ -111,7 +111,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !canPerformActions },
];
// --- 新增:多选下载 ---
// --- 多选下载 ---
// 多选时暂时禁用文件夹下载,只允许下载文件
// 如果需要支持多选文件夹下载或混合下载,需要更复杂的逻辑和后端支持(例如打包成 zip)
// 目前仅在 allFilesSelected 为 true 时启用多文件下载
@@ -31,7 +31,7 @@ const sortFiles = (a: FileListItem, b: FileListItem): number => {
return a.filename.localeCompare(b.filename);
};
// *** 新增:文件树节点接口 ***
// *** 文件树节点接口 ***
export interface FileTreeNode {
filename: string;
longname: string; // 保留 longname 以便显示
@@ -61,16 +61,16 @@ export function createSftpActionsManager(
// const fileList = ref<FileListItem[]>([]); // 不再直接使用 fileList ref
const isLoading = ref<boolean>(false);
const loadingRequestId = ref<string | null>(null); // 新增:跟踪当前加载请求 ID
const loadingRequestId = ref<string | null>(null); // 跟踪当前加载请求 ID
// const error = ref<string | null>(null); // 不再使用本地 error ref
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const uiNotificationsStore = useUiNotificationsStore(); // 初始化 UI 通知 store
const initialLoadDone = ref<boolean>(false); // +++ 新增:跟踪此实例是否已完成初始加载 +++
const initialLoadDone = ref<boolean>(false); // +++ 跟踪此实例是否已完成初始加载 +++
// 用于存储注销函数的数组
const unregisterCallbacks: (() => void)[] = [];
// *** 新增:响应式文件树 ***
// *** 响应式文件树 ***
const fileTree = reactive<FileTreeNode>({
filename: '/', // 根节点代表根目录
longname: '/',
@@ -223,7 +223,7 @@ export function createSftpActionsManager(
console.warn(`[SFTP ${instanceSessionId}] 尝试加载目录 ${path} 但 SFTP 未就绪。`); // 日志改为中文
return;
}
// *** 新增:如果已经在加载,则阻止新的加载请求 ***
// *** 如果已经在加载,则阻止新的加载请求 ***
if (isLoading.value) {
console.warn(`[SFTP ${instanceSessionId}] 尝试加载目录 ${path} 但已在加载中。`);
return;
@@ -415,7 +415,7 @@ export function createSftpActionsManager(
});
};
// +++ 新增:复制项目 +++
// +++ 复制项目 +++
const copyItems = (sourcePaths: string[], destinationDir: string) => {
if (!isSftpReady.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
@@ -433,7 +433,7 @@ export function createSftpActionsManager(
// 可选:显示一个“正在复制...”的通知
};
// +++ 新增:移动项目 +++
// +++ 移动项目 +++
const moveItems = (sourcePaths: string[], destinationDir: string) => {
if (!isSftpReady.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
@@ -579,12 +579,12 @@ export function createSftpActionsManager(
// 移除通用的 onActionSuccessRefresh
// *** 新增:具体操作成功后的处理函数 ***
// *** 具体操作成功后的处理函数 ***
// *** 移除旧的 invalidateCache ***
// const invalidateCache = (path: string) => { ... };
// *** 新增:辅助函数 - 从文件树中移除节点 ***
// *** 辅助函数 - 从文件树中移除节点 ***
const removeNodeFromTree = (parentPath: string, filename: string): boolean => {
const parentNode = findNodeByPath(fileTree, parentPath);
if (parentNode && parentNode.children) {
@@ -787,7 +787,7 @@ export function createSftpActionsManager(
}
};
// +++ 新增:处理复制成功 +++
// +++ 处理复制成功 +++
const onCopySuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// 后端应发送 { destination: string, items: FileListItem[] | null }
const copyPayload = payload as { destination: string, items: FileListItem[] | null };
@@ -825,7 +825,7 @@ export function createSftpActionsManager(
}
};
// +++ 新增:处理移动成功 +++
// +++ 处理移动成功 +++
const onMoveSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// 后端应发送 { sources: string[], destination: string, items: FileListItem[] | null }
const movePayload = payload as { sources: string[], destination: string, items: FileListItem[] | null };
@@ -867,7 +867,7 @@ export function createSftpActionsManager(
};
// *** 新增:处理上传成功 ***
// *** 处理上传成功 ***
const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
const newItem = payload as FileListItem | null; // 后端应发送 FileListItem 或 null
const fullPath = message.path; // 后端现在应该在 message 中包含完整的上传路径
@@ -944,14 +944,14 @@ export function createSftpActionsManager(
unregisterCallbacks.push(onMessage('sftp:rename:success', onRenameSuccess));
unregisterCallbacks.push(onMessage('sftp:chmod:success', onChmodSuccess));
unregisterCallbacks.push(onMessage('sftp:writefile:success', onWriteFileSuccess)); // 使用 onWriteFileSuccess
unregisterCallbacks.push(onMessage('sftp:upload:success', onUploadSuccess)); // *** 新增:监听上传成功 ***
unregisterCallbacks.push(onMessage('sftp:upload:success', onUploadSuccess)); // *** 监听上传成功 ***
unregisterCallbacks.push(onMessage('sftp:mkdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rmdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:unlink:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError));
// +++ 新增:监听复制/移动错误 +++
// +++ 监听复制/移动错误 +++
unregisterCallbacks.push(onMessage('sftp:copy:success', onCopySuccess));
unregisterCallbacks.push(onMessage('sftp:copy:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:move:success', onMoveSuccess));
@@ -959,7 +959,7 @@ export function createSftpActionsManager(
// 移除 onUnmounted 块
// *** 新增:计算属性 fileList ***
// *** 计算属性 fileList ***
const fileList = computed<FileListItem[]>(() => {
const node = findNodeByPath(fileTree, currentPathRef.value);
if (node && node.childrenLoaded && node.children) {
+17 -3
View File
@@ -418,7 +418,9 @@
"removeSelection": "Remove this tag selection",
"deleteTagGlobally": "Delete this tag globally",
"createSuccess": "Tag created successfully.",
"updateSuccess": "Tag updated successfully."
"updateSuccess": "Tag updated successfully.",
"deleteSuccess": "Tag \"{name}\" deleted successfully.",
"deleteFailed": "Failed to delete tag \"{name}\": {error}"
},
"settings": {
"title": "Settings",
@@ -928,9 +930,21 @@
"connectingAllSshInGroup": "Connecting {count} SSH connections in group '{groupName}'...",
"noSshConnectionsInGroup": "No SSH connections to connect in group '{groupName}'.",
"connectAllSshInGroupMenu": "Connect All",
"noSshConnectionsToConnectMenu": "No SSH Connections"
"noSshConnectionsToConnectMenu": "No SSH Connections",
"manageTags": {
"title": "Manage Tag Connections",
"searchPlaceholder": "Search connections...",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"noConnectionsFound": "No connections found.",
"saveSuccess": "Tag connections updated successfully.",
"saveFailed": "Failed to update tag connections.",
"menuItem": "Manage Tag",
"cannotManageUntagged": "Cannot manage connections for 'Untagged' group.",
"invertSelection": "Invert Selection"
}
},
"remoteDesktopModal": {
"remoteDesktopModal": {
"title": "Remote Desktop",
"titlePlaceholder": "Remote Desktop Connection",
"status": {
+16 -2
View File
@@ -1123,7 +1123,9 @@
"removeSelection": "このタグの選択を解除",
"title": "タグ管理",
"createSuccess": "タグが正常に作成されました。",
"updateSuccess": "タグが正常に更新されました。"
"updateSuccess": "タグが正常に更新されました。",
"deleteSuccess": "タグ「{name}」が正常に削除されました。",
"deleteFailed": "タグ「{name}」の削除に失敗しました: {error}"
},
"terminalTabBar": {
"selectServerTitle": "接続するサーバーを選択"
@@ -1147,7 +1149,19 @@
"connectingAllSshInGroup": "グループ '{groupName}' 内の {count} 個の SSH 接続に接続しています...",
"noSshConnectionsInGroup": "グループ '{groupName}' 内に接続可能な SSH 接続がありません。",
"connectAllSshInGroupMenu": "すべて接続",
"noSshConnectionsToConnectMenu": "SSH 接続なし"
"noSshConnectionsToConnectMenu": "SSH 接続なし",
"manageTags": {
"title": "タグ接続の管理",
"searchPlaceholder": "接続を検索...",
"selectAll": "すべて選択",
"deselectAll": "すべて選択解除",
"noConnectionsFound": "接続が見つかりません。",
"saveSuccess": "タグ接続が正常に更新されました。",
"saveFailed": "タグ接続の更新に失敗しました。",
"menuItem": "タグを管理",
"cannotManageUntagged": "「タグなし」グループの接続は管理できません。",
"invertSelection": "選択を反転"
}
},
"sshKeys": {
"selector": {
+16 -2
View File
@@ -417,7 +417,9 @@
"removeSelection": "移除此标签选择",
"deleteTagGlobally": "全局删除此标签",
"createSuccess": "标签创建成功。",
"updateSuccess": "标签更新成功。"
"updateSuccess": "标签更新成功。",
"deleteSuccess": "标签 \"{name}\" 删除成功。",
"deleteFailed": "标签 \"{name}\" 删除失败: {error}"
},
"settings": {
"title": "设置",
@@ -930,7 +932,19 @@
"connectingAllSshInGroup": "正在连接组 '{groupName}' 中的 {count} 个 SSH 连接...",
"noSshConnectionsInGroup": "组 '{groupName}' 中没有 SSH 类型的连接可供连接。",
"connectAllSshInGroupMenu": "连接全部",
"noSshConnectionsToConnectMenu": "无 SSH 连接"
"noSshConnectionsToConnectMenu": "无 SSH 连接",
"manageTags": {
"title": "管理标签连接",
"searchPlaceholder": "搜索连接...",
"selectAll": "全选",
"deselectAll": "取消全选",
"noConnectionsFound": "未找到连接。",
"saveSuccess": "标签连接更新成功。",
"saveFailed": "更新标签连接失败。",
"menuItem": "管理标签",
"cannotManageUntagged": "无法管理“未标记”分组的连接。",
"invertSelection": "反选"
}
},
"remoteDesktopModal": {
"title": "远程桌面",
@@ -10,9 +10,9 @@ export interface ConnectionInfo {
port: number;
username: string;
auth_method: 'password' | 'key';
proxy_id?: number | null; // 新增:关联的代理 ID (可选)
tag_ids?: number[]; // 新增:关联的标签 ID 数组 (可选)
ssh_key_id?: number | null; // +++ 新增:关联的 SSH 密钥 ID (可选) +++
proxy_id?: number | null; // 关联的代理 ID (可选)
tag_ids?: number[]; // 关联的标签 ID 数组 (可选)
ssh_key_id?: number | null; // +++ 关联的 SSH 密钥 ID (可选) +++
created_at: number;
updated_at: number;
last_connected_at: number | null;
@@ -98,7 +98,7 @@ export const useConnectionsStore = defineStore('connections', {
passphrase?: string; // SSH specific
vncPassword?: string; // VNC specific password
proxy_id?: number | null;
tag_ids?: number[]; // 新增:允许传入 tag_ids
tag_ids?: number[]; // 允许传入 tag_ids
}) {
this.isLoading = true;
this.error = null;
@@ -190,7 +190,7 @@ export const useConnectionsStore = defineStore('connections', {
}
},
// 新增:测试连接 Action
// 测试连接 Action
async testConnection(connectionId: number): Promise<{ success: boolean; message?: string }> {
// 注意:这里不改变 isLoading 状态,或者可以引入单独的 testing 状态
// this.isLoading = true;
@@ -211,7 +211,7 @@ export const useConnectionsStore = defineStore('connections', {
}
},
// 新增:克隆连接 Action (调用后端克隆接口)
// 克隆连接 Action (调用后端克隆接口)
async cloneConnection(originalId: number, newName: string): Promise<boolean> {
this.isLoading = true; // 可以考虑为克隆操作设置单独的加载状态
this.error = null;
@@ -237,7 +237,7 @@ export const useConnectionsStore = defineStore('connections', {
}
},
// +++ 新增:为多个连接添加一个标签 (调用新的后端 API) +++
// +++ 为多个连接添加一个标签 (调用新的后端 API) +++
async addTagToConnectionsAction(connectionIds: number[], tagId: number): Promise<boolean> {
if (connectionIds.length === 0) return true; // 没有连接需要更新,直接返回成功
@@ -285,7 +285,7 @@ export const useConnectionsStore = defineStore('connections', {
}
},
// +++ 新增:获取 VNC 会话令牌 +++
// +++ 获取 VNC 会话令牌 +++
async getVncSessionToken(connectionId: number, width?: number, height?: number): Promise<string | null> {
// this.isLoading = true; // 考虑是否需要独立的加载状态,或者由调用方处理
// this.error = null;
@@ -22,7 +22,7 @@ export interface FileTab {
filename: string;
content: string; // 当前解码后的内容 (前端解码)
originalContent: string; // 初始加载或上次保存时解码后的内容 (前端解码)
rawContentBase64: string | null; // +++ 新增:存储原始 Base64 数据 +++
rawContentBase64: string | null; // +++ 存储原始 Base64 数据 +++
language: string;
selectedEncoding: string; // 当前选择或自动检测到的编码
isLoading: boolean;
@@ -70,7 +70,7 @@ export const getFilenameFromPath = (filePath: string): string => {
return filePath.split('/').pop() || filePath;
};
// +++ 新增:前端解码辅助函数 +++
// +++ 前端解码辅助函数 +++
const decodeRawContent = (rawContentBase64: string, encoding: string): string => {
try {
const buffer = Buffer.from(rawContentBase64, 'base64');
@@ -106,8 +106,8 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
const tabs = ref(new Map<string, FileTab>()); // 存储所有打开的标签页 (使用 FileTab)
const activeTabId = ref<string | null>(null); // 当前激活的标签页 ID
// const editorVisibleState = ref<'visible' | 'minimized' | 'closed'>('closed'); // 移除,面板可见性由布局控制
const popupTrigger = ref(0); // 新增:用于触发弹窗显示的信号
const popupFileInfo = ref<{ filePath: string; sessionId: string } | null>(null); // 新增:存储弹窗文件信息
const popupTrigger = ref(0); // 用于触发弹窗显示的信号
const popupFileInfo = ref<{ filePath: string; sessionId: string } | null>(null); // 存储弹窗文件信息
// --- 计算属性 ---
const orderedTabs = computed(() => Array.from(tabs.value.values())); // 获取标签页数组,用于渲染
@@ -413,7 +413,7 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
// setEditorVisibility('closed'); // 移除:容器可见性由外部控制
};
// +++ 新增:关闭其他标签页 +++
// +++ 关闭其他标签页 +++
const closeOtherTabs = (targetTabId: string) => {
console.log(`[文件编辑器 Store] closeOtherTabs: Action called. Current keys in tabs map:`, Array.from(tabs.value.keys())); // ++ Log current keys at start
if (!tabs.value.has(targetTabId)) {
@@ -429,7 +429,7 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
});
};
// +++ 新增:关闭右侧标签页 +++
// +++ 关闭右侧标签页 +++
const closeTabsToTheRight = (targetTabId: string) => {
const tabsArray = Array.from(tabs.value.values());
const targetIndex = tabsArray.findIndex(tab => tab.id === targetTabId);
@@ -447,7 +447,7 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
});
};
// +++ 新增:关闭左侧标签页 +++
// +++ 关闭左侧标签页 +++
const closeTabsToTheLeft = (targetTabId: string) => {
const tabsArray = Array.from(tabs.value.values());
const targetIndex = tabsArray.findIndex(tab => tab.id === targetTabId);
@@ -34,7 +34,7 @@ interface FocusSwitcherState {
isConfiguratorVisible: boolean;
activateFileManagerSearchTrigger: number;
activateTerminalSearchTrigger: number;
// 新增:存储注册的聚焦动作
// 存储注册的聚焦动作
registeredActions: Map<string, Array<() => boolean | Promise<boolean | undefined>>>;
}
@@ -56,13 +56,13 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
{ id: 'fileEditorActive', label: t('focusSwitcher.input.fileEditorActive', '文件编辑器') },
{ id: 'fileManagerPathInput', label: t('focusSwitcher.input.fileManagerPathInput', '文件管理器路径编辑') },
]);
const sequenceOrder = ref<string[]>([]); // +++ 新增:存储顺序 +++
const itemConfigs = ref<Record<string, FocusItemConfig>>({}); // +++ 新增:存储所有配置 +++
const sequenceOrder = ref<string[]>([]); // +++ 存储顺序 +++
const itemConfigs = ref<Record<string, FocusItemConfig>>({}); // +++ 存储所有配置 +++
const isConfiguratorVisible = ref(false);
const activateFileManagerSearchTrigger = ref(0);
const activateTerminalSearchTrigger = ref(0);
// 新增:存储注册的聚焦动作 (Map: id -> Array of actions)
// 存储注册的聚焦动作 (Map: id -> Array of actions)
const registeredActions = ref<Map<string, Array<() => boolean | Promise<boolean | undefined>>>>(new Map());
// --- Actions ---
@@ -378,7 +378,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
return order[nextIndex]; // 返回序列中的下一个 ID
}
// +++ 新增:根据快捷键获取目标 ID +++
// +++ 根据快捷键获取目标 ID +++
// +++ 修改:根据 itemConfigs 获取快捷键对应的目标 ID +++
function getFocusTargetIdByShortcut(shortcut: string): string | null {
for (const id in itemConfigs.value) {
+4 -4
View File
@@ -168,9 +168,9 @@ export const useLayoutStore = defineStore('layout', () => {
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
'dockerManager', 'suspendedSshSessions' // <-- 添加新的挂起 SSH 会话视图
]);
// 新增:控制布局(Header/Footer)可见性的状态
// 控制布局(Header/Footer)可见性的状态
const isLayoutVisible: Ref<boolean> = ref(true); // 控制整体布局(Header/Footer)可见性
// 新增:控制主导航栏(Header)可见性的状态
// 控制主导航栏(Header)可见性的状态
const isHeaderVisible: Ref<boolean> = ref(true); // 默认可见
// --- 计算属性 ---
@@ -183,7 +183,7 @@ export const useLayoutStore = defineStore('layout', () => {
return allPossiblePanes.value.filter(pane => !used.has(pane));
});
// +++ 新增:递归确保节点及其子节点都有 ID +++
// +++ 递归确保节点及其子节点都有 ID +++
function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
if (!node) return null;
@@ -366,7 +366,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
}
}
// 新增:更新侧栏配置
// 更新侧栏配置
async function updateSidebarPanes(newPanes: { left: PaneName[], right: PaneName[] }) { // Make async
// --- Add Validation ---
if (newPanes &&
@@ -155,7 +155,7 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
return result;
});
// +++ 新增 Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++
// +++ Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++
const flatVisibleCommands = computed((): QuickCommandFE[] => {
const flatList: QuickCommandFE[] = [];
filteredAndGroupedCommands.value.forEach(group => {
@@ -4,14 +4,14 @@ import { sessions, suspendedSshSessions, isLoadingSuspendedSessions, activeSessi
import type {
MessagePayload,
SshMarkForSuspendReqMessage,
SshUnmarkForSuspendReqMessage, // +++ 新增导入 +++
SshUnmarkForSuspendReqMessage,
SshSuspendResumeReqMessage,
SshSuspendTerminateReqMessage,
SshSuspendRemoveEntryReqMessage,
// SshSuspendEditNameReqMessage, // Removed, using HTTP API
// S2C Payloads
SshMarkedForSuspendAckPayload,
SshUnmarkedForSuspendAckPayload, // +++ 新增导入 +++
SshUnmarkedForSuspendAckPayload,
SshSuspendListResponsePayload,
SshSuspendResumedNotifPayload,
SshOutputCachedChunkPayload,
@@ -27,7 +27,7 @@ import { useUiNotificationsStore } from '../../uiNotifications.store'; // 用于
import type { SuspendedSshSession } from '../../../types/ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts
import i18n from '../../../i18n'; // 直接导入 i18n 实例
import type { ComposerTranslation } from 'vue-i18n'; // 导入 ComposerTranslation 类型
import apiClient from '../../../utils/apiClient'; // +++ 新增:导入 apiClient +++
import apiClient from '../../../utils/apiClient'; // +++ 导入 apiClient +++
const t: ComposerTranslation = i18n.global.t; // 从全局 i18n 实例获取 t 函数并显式注解类型
@@ -721,7 +721,7 @@ export const registerSshSuspendHandlers = (wsManager: WsManagerInstance): void =
// 但通常这些处理器会随 wsManager 实例的生命周期一起存在。
// wsManager.onMessage('SSH_SUSPEND_STARTED_RESP', (p: MessagePayload) => handleSshSuspendStartedResp(p as SshSuspendStartedRespPayload));
wsManager.onMessage('SSH_MARKED_FOR_SUSPEND_ACK', (p: MessagePayload) => handleSshMarkedForSuspendAck(p as SshMarkedForSuspendAckPayload));
wsManager.onMessage('SSH_UNMARKED_FOR_SUSPEND_ACK', (p: MessagePayload) => handleSshUnmarkedForSuspendAck(p as SshUnmarkedForSuspendAckPayload)); // +++ 新增处理器 +++
wsManager.onMessage('SSH_UNMARKED_FOR_SUSPEND_ACK', (p: MessagePayload) => handleSshUnmarkedForSuspendAck(p as SshUnmarkedForSuspendAckPayload));
wsManager.onMessage('SSH_SUSPEND_LIST_RESPONSE', (p: MessagePayload) => handleSshSuspendListResponse(p as SshSuspendListResponsePayload));
wsManager.onMessage('SSH_SUSPEND_RESUMED_NOTIF', (p: MessagePayload) => handleSshSuspendResumedNotif(p as SshSuspendResumedNotifPayload));
wsManager.onMessage('SSH_OUTPUT_CACHED_CHUNK', (p: MessagePayload) => handleSshOutputCachedChunk(p as SshOutputCachedChunkPayload));
@@ -36,15 +36,15 @@ export interface SessionState {
terminalManager: SshTerminalInstance;
statusMonitorManager: StatusMonitorInstance;
dockerManager: DockerManagerInstance; // 现在应该可以找到 DockerManagerInstance
// --- 新增:独立编辑器状态 ---
// --- 独立编辑器状态 ---
editorTabs: Ref<FileTab[]>; // 编辑器标签页列表
activeEditorTabId: Ref<string | null>; // 当前活动的编辑器标签页 ID
// --- 新增:命令输入框内容 ---
// --- 命令输入框内容 ---
commandInputContent: Ref<string>; // 当前会话的命令输入框内容
isResuming?: boolean; // 新增:标记会话是否正在从挂起状态恢复
isMarkedForSuspend?: boolean; // +++ 新增:标记会话是否已被用户请求标记为待挂起 +++
disposables?: (() => void)[]; // 新增:用于存储清理函数,例如取消注册消息处理器
pendingOutput?: string[]; // 新增:用于暂存恢复会话时,在终端实例准备好之前收到的输出
isResuming?: boolean; // 标记会话是否正在从挂起状态恢复
isMarkedForSuspend?: boolean; // +++ 标记会话是否已被用户请求标记为待挂起 +++
disposables?: (() => void)[]; // 用于存储清理函数,例如取消注册消息处理器
pendingOutput?: string[]; // 用于暂存恢复会话时,在终端实例准备好之前收到的输出
}
// 为标签栏定义包含状态的类型
@@ -52,5 +52,5 @@ export interface SessionTabInfoWithStatus {
sessionId: string;
connectionName: string;
status: WsConnectionStatus; // 添加状态字段
isMarkedForSuspend?: boolean; // +++ 新增:用于UI指示会话是否已标记待挂起 +++
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
}
@@ -123,6 +123,36 @@ export const useTagsStore = defineStore('tags', () => {
}
}
// 更新标签关联的连接
async function updateTagConnections(tagId: number, connectionIds: number[]): Promise<boolean> {
isLoading.value = true;
error.value = null;
try {
// 假设后端 API 端点是 PUT /api/tags/:tagId/connections
await apiClient.put(`/tags/${tagId}/connections`, { connection_ids: connectionIds });
// 更新成功后,清除相关缓存并重新获取数据以确保一致性
localStorage.removeItem('tagsCache'); // 清除标签缓存
localStorage.removeItem('connectionsCache'); // 清除连接缓存,因为连接的 tag_ids 可能已更改
await fetchTags(); // 重新获取标签
// 可能还需要通知 connectionsStore 重新获取连接,或者在这里直接调用
// (这取决于您希望如何管理 store 间的依赖和数据同步)
// 例如: const connectionsStore = useConnectionsStore(); await connectionsStore.fetchConnections();
// 为简单起见,这里假设调用者会处理连接列表的刷新,或者依赖于后续的自动刷新机制。
// 或者,更健壮的做法是在此 action 成功后,让 connectionsStore 也刷新。
// 但为了减少此处的直接依赖,暂时只刷新 tagsStore。
// WorkspaceConnectionList 在模态框保存成功后会重新 fetchConnections。
return true;
} catch (err: any) {
console.error(`Failed to update connections for tag ${tagId}:`, err);
error.value = err.response?.data?.message || err.message || '更新标签连接失败';
return false;
} finally {
isLoading.value = false;
}
}
return {
tags,
isLoading,
@@ -131,5 +161,6 @@ export const useTagsStore = defineStore('tags', () => {
addTag,
updateTag,
deleteTag,
updateTagConnections, // 暴露新的 action
};
});
@@ -47,7 +47,7 @@ export interface SshSuspendEditNameReqPayload {
export interface SshMarkForSuspendReqPayload {
sessionId: string;
initialBuffer?: string; // +++ 新增:可选的初始屏幕缓冲区内容 +++
initialBuffer?: string; // +++ 可选的初始屏幕缓冲区内容 +++
}
export interface SshUnmarkForSuspendReqPayload {
@@ -61,7 +61,7 @@ export interface SshMarkedForSuspendAckPayload {
error?: string;
}
export interface SshUnmarkedForSuspendAckPayload { // +++ 新增 +++
export interface SshUnmarkedForSuspendAckPayload {
sessionId: string;
success: boolean;
error?: string;
@@ -151,7 +151,7 @@ export interface SshMarkForSuspendReqMessage extends WebSocketMessage {
payload: SshMarkForSuspendReqPayload;
}
export interface SshUnmarkForSuspendReqMessage extends WebSocketMessage { // +++ 新增 +++
export interface SshUnmarkForSuspendReqMessage extends WebSocketMessage {
type: 'SSH_UNMARK_FOR_SUSPEND';
payload: SshUnmarkForSuspendReqPayload;
}
@@ -162,7 +162,7 @@ export interface SshMarkedForSuspendAckMessage extends WebSocketMessage {
payload: SshMarkedForSuspendAckPayload;
}
export interface SshUnmarkedForSuspendAckMessage extends WebSocketMessage { // +++ 新增 +++
export interface SshUnmarkedForSuspendAckMessage extends WebSocketMessage {
type: 'SSH_UNMARKED_FOR_SUSPEND_ACK';
payload: SshUnmarkedForSuspendAckPayload;
}
@@ -216,11 +216,11 @@ export type SshSuspendC2SMessage =
| SshSuspendRemoveEntryReqMessage
| SshSuspendEditNameReqMessage
| SshMarkForSuspendReqMessage
| SshUnmarkForSuspendReqMessage; // +++ 新增 +++
| SshUnmarkForSuspendReqMessage;
export type SshSuspendS2CMessage =
| SshMarkedForSuspendAckMessage
| SshUnmarkedForSuspendAckMessage // +++ 新增 +++
| SshUnmarkedForSuspendAckMessage
| SshSuspendStartedRespMessage
| SshSuspendListResponseMessage
| SshSuspendResumedNotifMessage
@@ -230,14 +230,14 @@ const deleteSingleCommand = (id: number) => {
commandHistoryStore.deleteCommand(id);
};
// ()
// ()
const executeCommand = (command: string) => {
emitWorkspaceEvent('terminal:sendCommand', { command });
// Optionally reset selection after execution
// selectedIndex.value = -1; // REMOVED: Store handles index
};
// +++ +++
// +++ +++
const focusSearchInput = (): boolean => {
if (searchInputRef.value) {
searchInputRef.value.focus();
@@ -44,7 +44,7 @@ const getInitialSelectedTagId = (): number | null => {
return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null;
};
const selectedTagId = ref<number | null>(getInitialSelectedTagId());
const searchQuery = ref(''); // +++ +++
const searchQuery = ref('');
// +++ / +++
const showAddEditConnectionForm = ref(false);
@@ -280,7 +280,7 @@ watch(editingTagId, async (newId) => {
}
});
//
//
watch(showQuickCommandTagsBoolean, () => {
quickCommandsStore.resetSelection();
});
@@ -432,7 +432,7 @@ const executeCommand = (command: QuickCommandFE) => {
// selectedIndex.value = -1; // REMOVED: Store handles index
};
// +++ +++
// +++ +++
const focusSearchInput = (): boolean => {
if (searchInputRef.value) {
searchInputRef.value.focus();
@@ -75,7 +75,7 @@ const currentSearchTerm = ref(''); // 当前搜索的关键词
const mobileTerminalRef = ref<InstanceType<typeof Terminal> | null>(null); // +++ mobileTerminalRef +++
const isVirtualKeyboardVisible = ref(true); // +++ State for virtual keyboard visibility +++
// --- ---
// --- ---
const handleGlobalKeyDown = (event: KeyboardEvent) => {
// Alt /
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
@@ -422,7 +422,7 @@ const handleCloseSearch = () => { // +++ 修改 +++
}
};
// +++ +++
// +++ +++
const handleClearTerminal = () => { // +++ +++
const currentSession = activeSession.value;
if (!currentSession) {
@@ -511,7 +511,7 @@ const handleCloseEditorTab = (tabId: string) => {
}
};
// +++ +++
// +++ +++
const handleChangeEncoding = (payload: { tabId: string; encoding: string }) => {
const isShared = shareFileEditorTabsBoolean.value;
console.log(`[WorkspaceView] handleChangeEncoding for tab ${payload.tabId} to ${payload.encoding}, Shared mode: ${isShared}`);
@@ -543,7 +543,7 @@ const handleCloseEditorTab = (tabId: string) => {
sessionStore.handleOpenNewSession(id);
};
// +++ +++
// +++ +++
const handleVirtualKeyPress = (keySequence: string) => {
const currentSession = activeSession.value;
if (!currentSession) {