feat(frontend): 增强工作台快捷指令与仪表盘能力
补充快捷指令动态变量解析与编辑弹窗一键插入, 统一列表执行、粘贴到终端和批量发送的处理链路 扩展快捷命令右键菜单动作,并为文件面板新增 多根目录资源管理器式侧栏浏览体验 为首页 dashboard 增加当前用户与系统总览双视角的 实时会话指标展示,并同步更新相关知识库记录
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user