feat(frontend): 增强工作台快捷指令与仪表盘能力

补充快捷指令动态变量解析与编辑弹窗一键插入,
统一列表执行、粘贴到终端和批量发送的处理链路

扩展快捷命令右键菜单动作,并为文件面板新增
多根目录资源管理器式侧栏浏览体验

为首页 dashboard 增加当前用户与系统总览双视角的
实时会话指标展示,并同步更新相关知识库记录
This commit is contained in:
yinjianm
2026-03-26 01:39:42 +08:00
parent a2ac4047d9
commit 3f6e2bffc6
35 changed files with 2206 additions and 190 deletions
@@ -42,6 +42,39 @@
<button type="button" @click="addVariable" class="mt-3 w-full py-2 px-4 border border-primary/50 text-primary text-sm rounded-md hover:bg-primary/10 transition-colors duration-150">
{{ t('quickCommands.form.addVariable', '+ 添加变量') }}
</button>
<div class="mt-5 border-t border-border/40 pt-4 space-y-4">
<div>
<h3 class="text-md font-medium text-text-secondary">{{ t('quickCommands.form.dynamicVariables.title', '动态变量') }}</h3>
<p class="mt-1 text-xs leading-5 text-text-tertiary">
{{ t('quickCommands.form.dynamicVariables.description', '点击下方变量即可插入到指令中,执行时会自动填充。') }}
</p>
</div>
<div v-for="group in dynamicVariableGroups" :key="group.key" class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-text-tertiary">
{{ t(group.titleKey, group.fallbackTitle) }}
</p>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="w-full rounded-lg border border-border/40 bg-input/20 px-3 py-2 text-left transition-colors duration-150 hover:border-primary/40 hover:bg-primary/10"
@click="insertDynamicVariable(item.insertValue)"
>
<div class="flex items-start justify-between gap-2">
<span class="text-sm font-medium text-foreground">{{ t(item.labelKey, item.key) }}</span>
<code class="rounded bg-background/80 px-1.5 py-0.5 text-[11px] text-primary">{{ item.insertValue }}</code>
</div>
<p class="mt-1 text-xs leading-5 text-text-secondary">
{{ t(item.descriptionKey, item.key) }}
</p>
<p class="mt-1 text-[11px] text-text-tertiary">
{{ t('quickCommands.form.dynamicVariables.exampleLabel', '示例') }}: <code>{{ item.example }}</code>
</p>
</button>
</div>
</div>
</div>
<!-- 右侧现有表单 -->
@@ -60,6 +93,7 @@
<div class="flex flex-col flex-grow">
<label for="qc-command" class="block mb-1.5 text-sm font-medium text-text-secondary">{{ t('quickCommands.form.command', '指令:') }} <span class="text-error">*</span></label>
<textarea
ref="commandTextareaRef"
id="qc-command"
v-model="formData.command"
required
@@ -105,17 +139,25 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue';
import { useResizable } from '../composables/useResizable';
import { useI18n } from 'vue-i18n';
import { useQuickCommandsStore, type QuickCommandFE } from '../stores/quickCommands.store';
import { useQuickCommandTagsStore } from '../stores/quickCommandTags.store';
import { useConnectionsStore } from '../stores/connections.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import { useSessionStore } from '../stores/session.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import TagInput from './TagInput.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
import { useAlertDialog } from '../composables/useAlertDialog';
import {
DYNAMIC_VARIABLE_DEFINITIONS,
resolveQuickCommandTemplate,
type DynamicVariableDefinition,
type QuickCommandTemplateWarning,
} from '../utils/quickCommandTemplate';
const props = defineProps<{
commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象 (应包含标签ID和变量)
@@ -128,12 +170,15 @@ const { showConfirmDialog } = useConfirmDialog();
const { showAlertDialog } = useAlertDialog();
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
const connectionsStore = useConnectionsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const sessionStore = useSessionStore();
const uiNotificationsStore = useUiNotificationsStore();
const emitWorkspaceEvent = useWorkspaceEventEmitter();
const isSubmitting = ref(false);
const modalContentRef = ref<HTMLElement | null>(null);
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
const R_MIN_WIDTH = 800; // 可调整大小的最小宽度 (像素)
const R_MIN_HEIGHT = 700; // 可调整大小的最小高度 (像素)
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
@@ -155,6 +200,30 @@ const localVariables = ref<{ name: string; value: string; id: string }[]>([]);
const commandError = ref<string | null>(null);
const dynamicVariableGroups = computed(() => {
const groups: Array<{ key: string; titleKey: string; fallbackTitle: string; items: DynamicVariableDefinition[] }> = [
{
key: 'datetime',
titleKey: 'quickCommands.form.dynamicVariables.groups.datetime',
fallbackTitle: '日期时间',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'datetime'),
},
{
key: 'identity',
titleKey: 'quickCommands.form.dynamicVariables.groups.identity',
fallbackTitle: '唯一标识',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'identity'),
},
{
key: 'system',
titleKey: 'quickCommands.form.dynamicVariables.groups.system',
fallbackTitle: '系统',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'system'),
},
];
return groups.filter((group) => group.items.length > 0);
});
// 监听指令内容变化,进行校验
watch(() => formData.command, (newCommand) => {
@@ -277,47 +346,80 @@ const deleteVariable = (variableId: string) => {
localVariables.value = localVariables.value.filter(v => v.id !== variableId);
};
// 使用当前变量执行命令
const handleExecute = () => {
let processedCommand = formData.command;
const currentVariables = localVariables.value.reduce((acc, curr) => {
const collectCurrentVariables = () => {
return localVariables.value.reduce((acc, curr) => {
if (curr.name.trim()) {
acc[curr.name.trim()] = curr.value;
}
return acc;
}, {} as Record<string, string>);
};
// 执行变量替换
for (const varName in currentVariables) {
const placeholder = new RegExp(`\\$\\{${varName}\\}`, 'g');
processedCommand = processedCommand.replace(placeholder, currentVariables[varName]);
}
// 检查模板中是否存在未定义的变量
const variablePlaceholders = formData.command.match(/\$\{[^\}]+\}/g) || [];
const undefinedVariables: string[] = [];
variablePlaceholders.forEach(placeholder => {
const varName = placeholder.substring(2, placeholder.length - 1);
if (!currentVariables.hasOwnProperty(varName)) {
undefinedVariables.push(varName);
}
});
const notifyTemplateWarnings = (undefinedVariables: string[], warnings: QuickCommandTemplateWarning[]) => {
if (undefinedVariables.length > 0) {
uiNotificationsStore.showWarning(
t('quickCommands.form.warningUndefinedVariables', { variables: undefinedVariables.join(', ') })
);
}
warnings.forEach((warning) => {
if (warning.code === 'clipboardUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.clipboardUnavailable', '无法读取剪贴板内容,已按空文本处理。'));
} else if (warning.code === 'passwordUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.passwordUnavailable', '当前活动连接没有可用的登录密码,已按空文本处理。'));
} else if (warning.code === 'unknownDynamicVariable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.unknownVariable', { variable: warning.variable }));
}
});
};
const getActiveSessionIdOrNotify = () => {
const activeSessionId = sessionStore.activeSessionId;
if (!activeSessionId) {
uiNotificationsStore.showError(t('quickCommands.form.errorNoActiveSession', '没有活动的SSH会话可执行指令。'));
return null;
}
return activeSessionId;
};
const insertDynamicVariable = async (placeholderValue: string) => {
const textarea = commandTextareaRef.value;
if (!textarea) {
formData.command += placeholderValue;
return;
}
console.log(`[QuickCmdForm] Executing processed command: "${processedCommand}" on session ${activeSessionId}`);
const selectionStart = textarea.selectionStart ?? formData.command.length;
const selectionEnd = textarea.selectionEnd ?? formData.command.length;
formData.command = `${formData.command.slice(0, selectionStart)}${placeholderValue}${formData.command.slice(selectionEnd)}`;
await nextTick();
textarea.focus();
const nextCursorPosition = selectionStart + placeholderValue.length;
textarea.setSelectionRange(nextCursorPosition, nextCursorPosition);
};
// 使用当前变量执行命令
const handleExecute = async () => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
const result = await resolveQuickCommandTemplate(formData.command, {
customVariables: collectCurrentVariables(),
sessionId: activeSessionId,
sessions: sessionStore.sessions,
connections: connectionsStore.connections,
fetchLoginCredentialDetails: loginCredentialsStore.fetchLoginCredentialDetails,
});
notifyTemplateWarnings(result.undefinedVariables, result.warnings);
console.log(`[QuickCmdForm] Executing processed command: "${result.command}" on session ${activeSessionId}`);
emitWorkspaceEvent('quickCommand:executeProcessed', {
command: processedCommand,
command: result.command,
sessionId: activeSessionId
});
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DashboardSummary } from '../types/server.types';
const props = defineProps<{
summary: DashboardSummary | null;
isLoading: boolean;
}>();
const { t, locale } = useI18n();
const summaryAvailable = computed(() => !!props.summary);
const liveMetricGroups = computed(() => {
if (!props.summary) {
return [];
}
return [
{
key: 'currentUser',
title: t('dashboard.liveMetrics.currentUser.title'),
description: t('dashboard.liveMetrics.currentUser.description'),
items: [
{
key: 'activeSshSessions',
label: t('dashboard.liveMetrics.labels.activeSshSessions'),
value: formatNumber(props.summary.liveMetrics.currentUser.activeSshSessions),
icon: 'fa-terminal',
iconClass: 'text-emerald-400',
},
{
key: 'suspendedSessions',
label: t('dashboard.liveMetrics.labels.suspendedSessions'),
value: formatNumber(props.summary.liveMetrics.currentUser.suspendedSessions),
icon: 'fa-pause-circle',
iconClass: 'text-amber-400',
},
],
},
{
key: 'system',
title: t('dashboard.liveMetrics.system.title'),
description: t('dashboard.liveMetrics.system.description'),
items: [
{
key: 'activeSshSessions',
label: t('dashboard.liveMetrics.labels.activeSshSessions'),
value: formatNumber(props.summary.liveMetrics.system.activeSshSessions),
icon: 'fa-network-wired',
iconClass: 'text-sky-400',
},
{
key: 'suspendedSessions',
label: t('dashboard.liveMetrics.labels.suspendedSessions'),
value: formatNumber(props.summary.liveMetrics.system.suspendedSessions),
icon: 'fa-layer-group',
iconClass: 'text-violet-400',
},
{
key: 'statusStreams',
label: t('dashboard.liveMetrics.labels.statusStreams'),
value: formatNumber(props.summary.liveMetrics.system.statusStreams),
icon: 'fa-heart-pulse',
iconClass: 'text-rose-400',
},
],
},
];
});
function formatNumber(value: number): string {
return new Intl.NumberFormat(locale.value).format(value);
}
</script>
<template>
<section class="rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="mb-4">
<h2 class="text-lg font-medium">{{ t('dashboard.liveMetrics.title') }}</h2>
<p class="text-sm text-text-secondary">{{ t('dashboard.liveMetrics.description') }}</p>
</div>
<div v-if="isLoading && !summaryAvailable" class="text-center text-text-secondary">
{{ t('common.loading') }}
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<section
v-for="group in liveMetricGroups"
:key="group.key"
class="rounded-xl border border-border/70 bg-header/30 p-4"
>
<div class="mb-3">
<h3 class="text-base font-medium">{{ group.title }}</h3>
<p class="text-sm text-text-secondary">{{ group.description }}</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<article
v-for="item in group.items"
:key="item.key"
class="rounded-lg border border-border/70 bg-card/80 px-3 py-3"
>
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-sm text-text-secondary">{{ item.label }}</span>
<i :class="['fas', item.icon, item.iconClass]"></i>
</div>
<p class="text-2xl font-semibold leading-none">{{ item.value }}</p>
</article>
</div>
</section>
</div>
</section>
</template>
@@ -17,6 +17,7 @@ import { Bar, Doughnut, Line } from 'vue-chartjs';
import { format, formatDistanceToNow } from 'date-fns';
import { enUS, ja, zhCN } from 'date-fns/locale';
import type { Locale } from 'date-fns';
import DashboardLiveMetricsPanel from './DashboardLiveMetricsPanel.vue';
import type { ConnectionInfo } from '../stores/connections.store';
import type { DashboardSummary } from '../types/server.types';
@@ -315,6 +316,8 @@ function handleConnect(connection: ConnectionInfo | null): void {
{{ t('dashboard.summaryLoadFailed') }}: {{ error }}
</div>
<DashboardLiveMetricsPanel :summary="summary" :is-loading="isLoading" />
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<section class="rounded-xl border border-border bg-card p-4 shadow-sm xl:col-span-2">
<div class="mb-4 flex items-start justify-between gap-3">
+352 -51
View File
@@ -3,12 +3,13 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
import { createSftpActionsManager, type WebSocketDependencies, type FileTreeNode } from '../composables/useSftpActions';
import { useFileUploader } from '../composables/useFileUploader';
import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store';
import { useSessionStore } from '../stores/session.store';
import { useSettingsStore } from '../stores/settings.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useFavoritePathsStore, type FavoritePathItem } from '../stores/favoritePaths.store';
import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } from '../composables/file-manager/useFileManagerContextMenu';
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
@@ -26,6 +27,30 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
type ExplorerRootSource = 'favorite' | 'current';
interface ExplorerRootItem {
id: string;
path: string;
label: string;
description: string;
source: ExplorerRootSource;
}
interface ExplorerTreeRow {
id: string;
path: string;
name: string;
description?: string;
depth: number;
isDirectory: boolean;
isRoot: boolean;
loaded: boolean;
expanded: boolean;
source: ExplorerRootSource | 'tree';
item: FileListItem;
}
// --- Props ---
const props = defineProps({
@@ -58,6 +83,7 @@ const props = defineProps({
const { t } = useI18n();
const route = useRoute(); // Keep for download URL generation for now
const sessionStore = useSessionStore(); // 实例化 Session Store
const favoritePathsStore = useFavoritePathsStore();
// --- 获取并存储 SFTP 管理器实例 ---
// 使用 shallowRef 存储管理器实例,以便在 sessionId 变化时切换
@@ -113,6 +139,7 @@ const {
showPopupFileEditorBoolean, // +++ 获取弹窗设置状态 +++
fileManagerShowDeleteConfirmationBoolean, // +++ 获取删除确认设置状态 +++
} = storeToRefs(settingsStore); // 使用 storeToRefs 保持响应性
const { favoritePaths } = storeToRefs(favoritePathsStore);
@@ -133,6 +160,7 @@ const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
const favoritePathsButtonRef = ref<HTMLButtonElement | null>(null); // Ref for the trigger button
const explorerExpandedPaths = ref<Record<string, boolean>>({});
// +++ Path History Refs +++
const showPathHistoryDropdown = ref(false);
@@ -190,6 +218,173 @@ const formatMode = (mode: number): string => {
return str;
};
const getPathName = (path: string): string => {
if (!path || path === '/') {
return '/';
}
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
return normalized.substring(normalized.lastIndexOf('/') + 1) || normalized;
};
const sortTreeItems = (items: FileListItem[]): FileListItem[] => {
return [...items].sort((left, right) => {
if (left.attrs.isDirectory && !right.attrs.isDirectory) return -1;
if (!left.attrs.isDirectory && right.attrs.isDirectory) return 1;
return left.filename.localeCompare(right.filename);
});
};
const findTreeNodeByPath = (path: string): FileTreeNode | null => {
const root = currentSftpManager.value?.fileTree;
if (!root) {
return null;
}
if (path === '/') {
return root;
}
const segments = path.split('/').filter(Boolean);
let currentNode: FileTreeNode | null = root;
for (const segment of segments) {
if (!currentNode?.children) {
return null;
}
currentNode = currentNode.children.find((child) => child.filename === segment) ?? null;
}
return currentNode;
};
const toFileListItem = (node: FileTreeNode): FileListItem => ({
filename: node.filename,
longname: node.longname,
attrs: node.attrs,
});
const openFileInWorkspace = (filePath: string, filename: string) => {
const fileInfo: FileInfo = { name: filename, fullPath: filePath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(filePath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(filePath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
};
const explorerRoots = computed<ExplorerRootItem[]>(() => {
const roots = new Map<string, ExplorerRootItem>();
favoritePaths.value.forEach((favorite: FavoritePathItem) => {
const path = favorite.path?.trim();
if (!path) {
return;
}
roots.set(path, {
id: `favorite:${favorite.id}`,
path,
label: favorite.name?.trim() || getPathName(path),
description: path,
source: 'favorite',
});
});
const currentPath = currentSftpManager.value?.currentPath.value?.trim();
if (currentPath && !roots.has(currentPath)) {
roots.set(currentPath, {
id: `current:${currentPath}`,
path: currentPath,
label: getPathName(currentPath),
description: currentPath,
source: 'current',
});
}
return Array.from(roots.values());
});
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
const rows: ExplorerTreeRow[] = [];
const appendNodeRows = (basePath: string, nodes: FileListItem[], depth: number) => {
sortTreeItems(nodes).forEach((item) => {
const itemPath = currentSftpManager.value?.joinPath(basePath, item.filename) ?? `${basePath}/${item.filename}`;
const treeNode = findTreeNodeByPath(itemPath);
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
const loaded = item.attrs.isDirectory ? Boolean(treeNode?.childrenLoaded) : true;
rows.push({
id: `tree:${itemPath}`,
path: itemPath,
name: item.filename,
depth,
isDirectory: item.attrs.isDirectory,
isRoot: false,
loaded,
expanded,
source: 'tree',
item,
});
if (item.attrs.isDirectory && expanded && treeNode?.children?.length) {
appendNodeRows(itemPath, treeNode.children.map(toFileListItem), depth + 1);
}
});
};
explorerRoots.value.forEach((root) => {
const node = findTreeNodeByPath(root.path);
const rootItem: FileListItem = node
? toFileListItem(node)
: {
filename: getPathName(root.path),
longname: root.path,
attrs: {
isDirectory: true,
isFile: false,
isSymbolicLink: false,
size: 0,
uid: 0,
gid: 0,
mode: 0,
atime: 0,
mtime: 0,
},
};
const expanded = explorerExpandedPaths.value[root.path] ?? true;
const loaded = Boolean(node?.childrenLoaded);
rows.push({
id: root.id,
path: root.path,
name: root.label,
description: root.description,
depth: 0,
isDirectory: true,
isRoot: true,
loaded,
expanded,
source: root.source,
item: rootItem,
});
if (expanded && node?.children?.length) {
appendNodeRows(root.path, node.children.map(toFileListItem), 1);
}
});
return rows;
});
const getFileIconClassBase = (filename: string): string => {
const lowerFilename = filename.toLowerCase();
let extension = '';
@@ -395,7 +590,6 @@ const handleItemAction = (item: FileListItem) => {
currentSftpManager.value.loadDirectory(realPath);
} else if (targetType === 'file') {
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename; // Get filename from realPath
const fileInfo: FileInfo = { name: targetFilename, fullPath: realPath };
// Preserve mobile multi-select behavior for the original link item
if (props.isMobile && isMultiSelectMode.value) {
@@ -407,27 +601,12 @@ const handleItemAction = (item: FileListItem) => {
return;
}
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(realPath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(realPath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(realPath, targetFilename);
} else { // targetType is 'unknown' or not provided as expected
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Symlink target '${realPath}' has an unknown type from server ('${targetType}'). Defaulting to open as file.`);
// Fallback: attempt to open as file, or display an error
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename;
const fileInfo: FileInfo = { name: targetFilename, fullPath: realPath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(realPath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(realPath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(realPath, targetFilename);
}
};
@@ -501,17 +680,7 @@ const handleItemAction = (item: FileListItem) => {
return;
}
const filePath = itemPath; // itemPath is already calculated
const fileInfo: FileInfo = { name: item.filename, fullPath: filePath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(filePath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(filePath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(filePath, item.filename);
}
};
@@ -1643,12 +1812,70 @@ const handleOpenEditorClick = () => {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Toggled FavoritePathsModal. Visible: ${showFavoritePathsModal.value}`);
};
const handleNavigateToPathFromFavorites = (path: string) => {
if (currentSftpManager.value) {
const handleNavigateToPathFromFavorites = (path: string) => {
if (currentSftpManager.value) {
currentSftpManager.value.loadDirectory(path);
}
showFavoritePathsModal.value = false; // Close modal after navigation
};
explorerExpandedPaths.value[path] = true;
}
showFavoritePathsModal.value = false; // Close modal after navigation
};
const handleExplorerToggle = (row: ExplorerTreeRow) => {
if (!row.isDirectory) {
return;
}
const nextExpanded = !(explorerExpandedPaths.value[row.path] ?? row.expanded);
explorerExpandedPaths.value[row.path] = nextExpanded;
if (nextExpanded && !row.loaded && currentSftpManager.value) {
currentSftpManager.value.loadDirectory(row.path);
return;
}
if (currentSftpManager.value?.currentPath.value !== row.path) {
currentSftpManager.value?.loadDirectory(row.path);
}
};
const handleExplorerOpen = (row: ExplorerTreeRow) => {
if (row.isDirectory) {
explorerExpandedPaths.value[row.path] = true;
currentSftpManager.value?.loadDirectory(row.path);
return;
}
openFileInWorkspace(row.path, row.name);
};
const isExplorerRowActive = (row: ExplorerTreeRow) => {
return currentSftpManager.value?.currentPath.value === row.path;
};
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
const currentPath = currentSftpManager.value?.currentPath.value;
if (!currentPath) {
return false;
}
if (row.path === '/') {
return true;
}
return currentPath === row.path || currentPath.startsWith(`${row.path}/`);
};
watch(
explorerRoots,
(roots) => {
roots.forEach((root) => {
if (explorerExpandedPaths.value[root.path] === undefined) {
explorerExpandedPaths.value[root.path] = true;
}
});
},
{ immediate: true },
);
</script>
<template>
@@ -1833,22 +2060,95 @@ const handleOpenEditorClick = () => {
</div>
</div>
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
<aside class="w-[260px] flex-shrink-0 border-r border-border/60 bg-header/40 flex flex-col min-h-0">
<div class="px-3 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
<div class="mt-1 text-xs text-text-secondary">{{ explorerRoots.length }} {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
</div>
<button
@click="toggleFavoritePathsModal"
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
:title="t('favoritePaths.addNew', 'Add new favorite path')"
>
<i class="fas fa-plus text-xs"></i>
</button>
</div>
</div>
<!-- File List Container -->
<div
ref="fileListContainerRef"
class="flex-grow overflow-y-auto relative outline-none"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="fileListContainerRef?.focus()"
@keydown="handleKeydown"
@wheel="handleWheel"
@contextmenu.prevent="showContextMenu($event)"
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<div class="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div v-if="explorerRoots.length === 0" class="px-3 py-6 text-xs text-text-secondary text-center">
{{ t('fileManager.explorer.noRoots', '暂无目录根请先添加收藏路径或连接后浏览当前目录') }}
</div>
<div v-else class="space-y-1">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
: isExplorerRowRelated(row)
? 'border-primary/20 bg-primary/8 text-foreground'
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
@click="handleExplorerOpen(row)"
>
<button
v-if="row.isDirectory"
@click.stop="handleExplorerToggle(row)"
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
<i class="fas fa-circle"></i>
</span>
<i
:class="[
row.isDirectory
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
: getFileIconClassBase(row.name),
'w-4 text-center flex-shrink-0',
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
]"
></i>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
<div
v-if="row.isRoot"
class="truncate text-[10px]"
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
>
{{ row.description }}
</div>
</div>
</div>
</div>
</div>
</aside>
<!-- File List Container -->
<div
ref="fileListContainerRef"
class="flex-grow overflow-y-auto relative outline-none"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="fileListContainerRef?.focus()"
@keydown="handleKeydown"
@wheel="handleWheel"
@contextmenu.prevent="showContextMenu($event)"
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<!-- 外部文件拖拽蒙版 -->
<div
v-if="showExternalDropOverlay"
@@ -2006,7 +2306,8 @@ const handleOpenEditorClick = () => {
</tbody>
</table>
<!-- Removed separate loading/empty divs -->
</div>
</div>
</div>
<!-- 使用 FileUploadPopup 组件 -->
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />