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