feat: 添加标签管理模态框
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user