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" />
+75 -3
View File
@@ -389,8 +389,6 @@
"scopeSearchMode": "Matched paths are expanded",
"scopeTreeNoMatch": "No matching tree nodes",
"scopeDragPlaceholder": "Drag-reorder is reserved for now; only target placeholder feedback is shown.",
"scopePinAction": "Focus this scope",
"scopeDragAction": "Drag to reorder (reserved)",
"untaggedGroup": "Untagged",
"noUntaggedConnections": "No untagged connections found."
},
@@ -1379,16 +1377,73 @@
"addVariable": "+ Add Variable",
"execute": "Execute",
"warningUndefinedVariables": "Warning: Undefined variables in command template: {variables}",
"errorNoActiveSession": "No active SSH session to execute the command."
"errorNoActiveSession": "No active SSH session to execute the command.",
"dynamicVariables": {
"title": "Dynamic Variables",
"description": "Click a variable below to insert it into the command. It will be resolved automatically at execution time.",
"exampleLabel": "Example",
"groups": {
"datetime": "Date & Time",
"identity": "Identifiers",
"system": "System"
},
"items": {
"date": {
"label": "date",
"description": "Current date. Supports custom formats such as YYYY-MM-DD, YYYYMMDD, and MM/DD."
},
"time": {
"label": "time",
"description": "Current time. Supports custom formats such as HH:mm:ss, HHmmss, and HH:mm."
},
"timestamp": {
"label": "timestamp",
"description": "Unix timestamp in seconds."
},
"week": {
"label": "week",
"description": "Current ISO week number in the year."
},
"uuid": {
"label": "uuid",
"description": "Generate a unique identifier."
},
"random": {
"label": "random",
"description": "Generate a random string. Use forms like random:8 to control the length."
},
"clipboard": {
"label": "clipboard",
"description": "Read text from the current clipboard."
},
"password": {
"label": "password",
"description": "Try to read the login password for the current active SSH session."
}
},
"warnings": {
"clipboardUnavailable": "Unable to read clipboard text. An empty string was used instead.",
"passwordUnavailable": "No login password is available for the current active connection. An empty string was used instead.",
"unknownVariable": "Unrecognized dynamic variable: {variable}"
}
}
},
"untagged": "Untagged",
"tags": {
"clickToEditTag": "Click to edit tag name"
},
"actions": {
"runNow": "Run Now",
"pasteToTerminal": "Paste to Terminal",
"copyCommand": "Copy Command",
"pasteToQuickInput": "Paste to Quick Input",
"edit": "Edit",
"delete": "Delete",
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"pastedToTerminal": "Pasted into the terminal input.",
"pastedToQuickInput": "Pasted into the quick input.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
@@ -1519,6 +1574,23 @@
"sshSuccess24h": "Successful SSH connection events over the last 24 hours",
"sshFailure24h": "Failed SSH or shell-open events over the last 24 hours"
},
"liveMetrics": {
"title": "Live Session Metrics",
"description": "A mixed view of your current sessions and the whole system runtime state.",
"currentUser": {
"title": "My Sessions",
"description": "Sessions tied to the current signed-in user."
},
"system": {
"title": "System Overview",
"description": "All live session signals currently tracked by the backend."
},
"labels": {
"activeSshSessions": "Active SSH Sessions",
"suspendedSessions": "Suspended Sessions",
"statusStreams": "Status Streams"
}
},
"charts": {
"activityTrend7d": "Activity Trend (7 Days)",
"activityTrendHint": "Daily audit event volume for the latest week",
+75 -3
View File
@@ -291,8 +291,6 @@
"scopeSearchMode": "一致したパスを自動展開中",
"scopeTreeNoMatch": "一致するツリーノードはありません",
"scopeDragPlaceholder": "ドラッグ並べ替えは予約中で、現在は配置先のプレースホルダーのみ表示します。",
"scopePinAction": "この範囲にフォーカス",
"scopeDragAction": "ドラッグで並べ替え(予約)",
"table": {
"actions": "アクション",
"authMethod": "認証方法",
@@ -361,6 +359,23 @@
"sshSuccess24h": "過去 24 時間に成功した SSH 接続イベント数",
"sshFailure24h": "過去 24 時間に失敗した SSH 接続または Shell 起動イベント数"
},
"liveMetrics": {
"title": "ライブセッション指標",
"description": "現在のユーザー視点とシステム全体視点を同時に表示します。",
"currentUser": {
"title": "自分のセッション",
"description": "現在ログイン中のユーザーに紐づくオンライン / 中断セッションです。"
},
"system": {
"title": "システム概要",
"description": "バックエンドが現在追跡している全体のライブセッション信号です。"
},
"labels": {
"activeSshSessions": "稼働中の SSH セッション",
"suspendedSessions": "中断セッション",
"statusStreams": "状態監視ストリーム"
}
},
"charts": {
"activityTrend7d": "直近 7 日のアクティビティ推移",
"activityTrendHint": "直近 1 週間の監査イベント数を日別に表示",
@@ -779,7 +794,56 @@
"addVariable": "+ 変数を追加",
"execute": "実行",
"warningUndefinedVariables": "警告:コマンドテンプレートに未定義の変数があります: {variables}",
"errorNoActiveSession": "コマンドを実行するためのアクティブなSSHセッションがありません。"
"errorNoActiveSession": "コマンドを実行するためのアクティブなSSHセッションがありません。",
"dynamicVariables": {
"title": "動的変数",
"description": "下の変数をクリックするとコマンドへ挿入され、実行時に自動で展開されます。",
"exampleLabel": "例",
"groups": {
"datetime": "日時",
"identity": "識別子",
"system": "システム"
},
"items": {
"date": {
"label": "date",
"description": "現在の日付です。YYYY-MM-DD、YYYYMMDD、MM/DD などの書式に対応します。"
},
"time": {
"label": "time",
"description": "現在の時刻です。HH:mm:ss、HHmmss、HH:mm などの書式に対応します。"
},
"timestamp": {
"label": "timestamp",
"description": "Unix タイムスタンプ(秒)です。"
},
"week": {
"label": "week",
"description": "現在が年内の第何週かを返します。"
},
"uuid": {
"label": "uuid",
"description": "一意の識別子を生成します。"
},
"random": {
"label": "random",
"description": "ランダム文字列を生成します。random:8 のように長さを指定できます。"
},
"clipboard": {
"label": "clipboard",
"description": "現在のクリップボードのテキストを読み取ります。"
},
"password": {
"label": "password",
"description": "現在アクティブな SSH セッションに対応するログインパスワードの取得を試みます。"
}
},
"warnings": {
"clipboardUnavailable": "クリップボードの内容を読み取れなかったため、空文字として扱いました。",
"passwordUnavailable": "現在のアクティブ接続に利用可能なログインパスワードがないため、空文字として扱いました。",
"unknownVariable": "未対応の動的変数があります: {variable}"
}
}
},
"untagged": "タグなし",
"tags": {
@@ -790,9 +854,17 @@
"sortByUsage": "使用頻度",
"usageCount": "使用回数",
"actions": {
"runNow": "今すぐ実行",
"pasteToTerminal": "ターミナルに貼り付け",
"copyCommand": "コマンドをコピー",
"pasteToQuickInput": "クイック入力欄に貼り付け",
"edit": "編集",
"delete": "削除",
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"pastedToTerminal": "ターミナル入力欄に貼り付けました。",
"pastedToQuickInput": "クイック入力欄に貼り付けました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
+75 -3
View File
@@ -389,8 +389,6 @@
"scopeSearchMode": "命中路径已自动展开",
"scopeTreeNoMatch": "没有匹配的树节点",
"scopeDragPlaceholder": "拖拽排序预留中,当前仅展示目标占位反馈。",
"scopePinAction": "定位到此范围",
"scopeDragAction": "拖拽重排(预留)",
"untaggedGroup": "未标记",
"noUntaggedConnections": "没有未标记的连接。"
},
@@ -1383,16 +1381,73 @@
"addVariable": "+ 添加变量",
"execute": "执行",
"warningUndefinedVariables": "警告:指令模板中存在未定义的变量: {variables}",
"errorNoActiveSession": "没有活动的SSH会话可执行指令。"
"errorNoActiveSession": "没有活动的SSH会话可执行指令。",
"dynamicVariables": {
"title": "动态变量",
"description": "点击下方变量即可插入到指令中,执行时会自动填充。",
"exampleLabel": "示例",
"groups": {
"datetime": "日期时间",
"identity": "唯一标识",
"system": "系统"
},
"items": {
"date": {
"label": "date",
"description": "当前日期,支持自定义格式,例如 YYYY-MM-DD、YYYYMMDD、MM/DD。"
},
"time": {
"label": "time",
"description": "当前时间,支持自定义格式,例如 HH:mm:ss、HHmmss、HH:mm。"
},
"timestamp": {
"label": "timestamp",
"description": "Unix 时间戳(秒)。"
},
"week": {
"label": "week",
"description": "当前是一年中的第几周。"
},
"uuid": {
"label": "uuid",
"description": "生成唯一标识符。"
},
"random": {
"label": "random",
"description": "生成随机字符串,可通过 random:8 这类写法指定长度。"
},
"clipboard": {
"label": "clipboard",
"description": "读取当前剪贴板文本内容。"
},
"password": {
"label": "password",
"description": "尝试读取当前活动 SSH 会话对应的登录密码。"
}
},
"warnings": {
"clipboardUnavailable": "无法读取剪贴板内容,已按空文本处理。",
"passwordUnavailable": "当前活动连接没有可用的登录密码,已按空文本处理。",
"unknownVariable": "存在未识别的动态变量: {variable}"
}
}
},
"untagged": "未标记",
"tags": {
"clickToEditTag": "点击编辑标签名称"
},
"actions": {
"runNow": "立即执行",
"pasteToTerminal": "粘贴到终端",
"copyCommand": "复制命令",
"pasteToQuickInput": "粘贴到快捷输入框",
"edit": "编辑",
"delete": "删除",
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"pastedToTerminal": "已粘贴到终端输入框。",
"pastedToQuickInput": "已粘贴到快捷输入框。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
@@ -1523,6 +1578,23 @@
"sshSuccess24h": "最近 24 小时内成功建立的 SSH 连接事件",
"sshFailure24h": "最近 24 小时内 SSH 连接或 Shell 打开失败事件"
},
"liveMetrics": {
"title": "实时会话指标",
"description": "同时展示当前用户视角与系统总览视角的运行态会话信息。",
"currentUser": {
"title": "我的会话",
"description": "与当前登录用户绑定的在线和挂起会话。"
},
"system": {
"title": "系统总览",
"description": "后端当前正在追踪的全局实时会话信号。"
},
"labels": {
"activeSshSessions": "在线 SSH 会话",
"suspendedSessions": "挂起会话",
"statusStreams": "状态监控流"
}
},
"charts": {
"activityTrend7d": "近 7 天活动趋势",
"activityTrendHint": "按天统计最近一周的审计事件量",
@@ -163,6 +163,22 @@ export interface DashboardActionBreakdownItem {
count: number;
}
export interface DashboardCurrentUserLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
}
export interface DashboardSystemLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
statusStreams: number;
}
export interface DashboardLiveMetrics {
currentUser: DashboardCurrentUserLiveMetrics;
system: DashboardSystemLiveMetrics;
}
export interface DashboardSummary {
totals: DashboardTotals;
sshOutcomes24h: DashboardSshOutcomes24h;
@@ -170,4 +186,5 @@ export interface DashboardSummary {
actionBreakdown7d: DashboardActionBreakdownItem[];
activityTrend7d: DashboardActivityTrendPoint[];
topConnections: DashboardTopConnection[];
liveMetrics: DashboardLiveMetrics;
}
@@ -0,0 +1,309 @@
import { format, getISOWeek } from 'date-fns';
import type { ConnectionInfo } from '../stores/connections.store';
import type { LoginCredentialDetails } from '../stores/loginCredentials.store';
import type { SessionState } from '../stores/session/types';
export interface DynamicVariableDefinition {
key: string;
insertValue: string;
example: string;
group: 'datetime' | 'identity' | 'system';
labelKey: string;
descriptionKey: string;
}
export interface QuickCommandTemplateWarning {
code: 'clipboardUnavailable' | 'passwordUnavailable' | 'unknownDynamicVariable';
variable: string;
}
export interface ResolveQuickCommandTemplateContext {
customVariables?: Record<string, string>;
sessionId?: string | null;
sessions?: Map<string, SessionState>;
connections?: ConnectionInfo[];
fetchLoginCredentialDetails?: (id: number) => Promise<LoginCredentialDetails | null>;
}
export interface ResolveQuickCommandTemplateResult {
command: string;
undefinedVariables: string[];
warnings: QuickCommandTemplateWarning[];
}
const CUSTOM_VARIABLE_PATTERN = /\$\{(?!\{)([^}]+)\}/g;
const DYNAMIC_VARIABLE_PATTERN = /\$\{\{([^{}]+)\}\}/g;
const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
const DEFAULT_TIME_FORMAT = 'HH:mm:ss';
const RANDOM_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
export const DYNAMIC_VARIABLE_DEFINITIONS: DynamicVariableDefinition[] = [
{
key: 'date',
insertValue: '${{date}}',
example: '${{date:YYYYMMDD}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.date.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.date.description',
},
{
key: 'time',
insertValue: '${{time}}',
example: '${{time:HHmmss}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.time.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.time.description',
},
{
key: 'timestamp',
insertValue: '${{timestamp}}',
example: '${{timestamp}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.timestamp.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.timestamp.description',
},
{
key: 'week',
insertValue: '${{week}}',
example: '${{week}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.week.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.week.description',
},
{
key: 'uuid',
insertValue: '${{uuid}}',
example: '${{uuid}}',
group: 'identity',
labelKey: 'quickCommands.form.dynamicVariables.items.uuid.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.uuid.description',
},
{
key: 'random',
insertValue: '${{random:8}}',
example: '${{random:8}}',
group: 'identity',
labelKey: 'quickCommands.form.dynamicVariables.items.random.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.random.description',
},
{
key: 'clipboard',
insertValue: '${{clipboard}}',
example: '${{clipboard}}',
group: 'system',
labelKey: 'quickCommands.form.dynamicVariables.items.clipboard.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.clipboard.description',
},
{
key: 'password',
insertValue: '${{password}}',
example: '${{password}}',
group: 'system',
labelKey: 'quickCommands.form.dynamicVariables.items.password.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.password.description',
},
];
export async function resolveQuickCommandTemplate(
template: string,
context: ResolveQuickCommandTemplateContext = {},
): Promise<ResolveQuickCommandTemplateResult> {
const customVariables = context.customVariables ?? {};
const undefinedVariables = new Set<string>();
const warnings = new Map<string, QuickCommandTemplateWarning>();
let processedCommand = template.replace(CUSTOM_VARIABLE_PATTERN, (fullMatch, rawVariableName: string) => {
const variableName = rawVariableName.trim();
if (Object.prototype.hasOwnProperty.call(customVariables, variableName)) {
return customVariables[variableName];
}
undefinedVariables.add(variableName);
return fullMatch;
});
const dynamicMatches = [...processedCommand.matchAll(DYNAMIC_VARIABLE_PATTERN)];
const now = new Date();
let cachedClipboard: string | undefined;
let clipboardLoaded = false;
let cachedPassword: string | undefined;
let passwordLoaded = false;
for (const match of dynamicMatches) {
const fullMatch = match[0];
const expression = match[1]?.trim() ?? '';
const [rawVariableName, rawArgument = ''] = expression.split(/:(.*)/s, 2);
const variableName = rawVariableName.trim().toLowerCase();
const argument = rawArgument.trim();
let replacement = fullMatch;
if (variableName === 'date') {
replacement = safeFormat(now, normalizeDateFormat(argument) || DEFAULT_DATE_FORMAT);
} else if (variableName === 'time') {
replacement = safeFormat(now, normalizeDateFormat(argument) || DEFAULT_TIME_FORMAT);
} else if (variableName === 'timestamp') {
replacement = String(Math.floor(now.getTime() / 1000));
} else if (variableName === 'week') {
replacement = String(getISOWeek(now));
} else if (variableName === 'uuid') {
replacement = generateUuid();
} else if (variableName === 'random') {
replacement = generateRandomString(parseRandomLength(argument));
} else if (variableName === 'clipboard') {
if (!clipboardLoaded) {
cachedClipboard = await readClipboardText();
clipboardLoaded = true;
}
replacement = cachedClipboard ?? '';
if (!replacement) {
warnings.set(`clipboard:${expression}`, {
code: 'clipboardUnavailable',
variable: expression,
});
}
} else if (variableName === 'password') {
if (!passwordLoaded) {
cachedPassword = await resolveSessionPassword(context);
passwordLoaded = true;
}
replacement = cachedPassword ?? '';
if (!replacement) {
warnings.set(`password:${expression}`, {
code: 'passwordUnavailable',
variable: expression,
});
}
} else {
warnings.set(`unknown:${expression}`, {
code: 'unknownDynamicVariable',
variable: expression,
});
}
processedCommand = processedCommand.replace(fullMatch, replacement);
}
return {
command: processedCommand,
undefinedVariables: [...undefinedVariables],
warnings: [...warnings.values()],
};
}
function normalizeDateFormat(input: string): string {
if (!input) {
return '';
}
return input
.replace(/YYYY/g, 'yyyy')
.replace(/YY/g, 'yy')
.replace(/DD/g, 'dd');
}
function safeFormat(date: Date, pattern: string): string {
try {
return format(date, pattern);
} catch {
return format(date, pattern.includes('H') || pattern.includes('m') || pattern.includes('s') ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT);
}
}
function parseRandomLength(rawLength: string): number {
const parsedLength = Number.parseInt(rawLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > 0) {
return parsedLength;
}
return 6;
}
function generateRandomString(length: number): string {
const result: string[] = [];
const randomBuffer = new Uint32Array(length);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(randomBuffer);
for (let index = 0; index < length; index += 1) {
result.push(RANDOM_CHARSET[randomBuffer[index] % RANDOM_CHARSET.length]);
}
return result.join('');
}
for (let index = 0; index < length; index += 1) {
result.push(RANDOM_CHARSET[Math.floor(Math.random() * RANDOM_CHARSET.length)]);
}
return result.join('');
}
function generateUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const randomBytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(randomBytes);
} else {
for (let index = 0; index < randomBytes.length; index += 1) {
randomBytes[index] = Math.floor(Math.random() * 256);
}
}
randomBytes[6] = (randomBytes[6] & 0x0f) | 0x40;
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80;
const hex = [...randomBytes].map((value) => value.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function readClipboardText(): Promise<string | undefined> {
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
return undefined;
}
try {
return await navigator.clipboard.readText();
} catch {
return undefined;
}
}
async function resolveSessionPassword(
context: ResolveQuickCommandTemplateContext,
): Promise<string | undefined> {
const { sessionId, sessions, connections, fetchLoginCredentialDetails } = context;
if (!sessionId || !sessions || !connections) {
return undefined;
}
const session = sessions.get(sessionId);
if (!session) {
return undefined;
}
const connection = connections.find((item) => String(item.id) === String(session.connectionId)) as
| (ConnectionInfo & { password?: string })
| undefined;
if (!connection) {
return undefined;
}
if (typeof connection.password === 'string' && connection.password.length > 0) {
return connection.password;
}
if (connection.login_credential_id && typeof fetchLoginCredentialDetails === 'function') {
const credential = await fetchLoginCredentialDetails(connection.login_credential_id);
if (credential?.password) {
return credential.password;
}
}
return undefined;
}
+48 -51
View File
@@ -4,8 +4,10 @@ import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
import { useConnectionsStore } from '../stores/connections.store';
import { useProxiesStore } from '../stores/proxies.store';
import { useSessionStore } from '../stores/session.store';
import { useTagsStore } from '../stores/tags.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import type { TagInfo } from '../stores/tags.store';
import type { SortField, SortOrder } from '../stores/settings.store';
import { useI18n } from 'vue-i18n';
@@ -45,11 +47,15 @@ const { t, locale } = useI18n();
const { showConfirmDialog } = useConfirmDialog();
const { showAlertDialog } = useAlertDialog();
const connectionsStore = useConnectionsStore();
const proxiesStore = useProxiesStore();
const sessionStore = useSessionStore();
const tagsStore = useTagsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
const { tags } = storeToRefs(tagsStore);
const { proxies } = storeToRefs(proxiesStore);
const { loginCredentials } = storeToRefs(loginCredentialsStore);
const LS_SORT_BY_KEY = 'connections_view_sort_by';
const LS_SORT_ORDER_KEY = 'connections_view_sort_order';
@@ -97,7 +103,6 @@ const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
const showBatchEditForm = ref(false);
const isDeletingSelectedConnections = ref(false);
const expandedTreeNodes = ref<Record<string, boolean>>({});
const hoveredTreeNodeId = ref<ScopeId | null>(null);
const draggingTreeNodeId = ref<ScopeId | null>(null);
const dropTargetTreeNodeId = ref<ScopeId | null>(null);
const treeDragNoticeVisible = ref(false);
@@ -158,6 +163,42 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
.filter((tagName): tagName is string => Boolean(tagName));
};
const getConnectionCredentialDisplay = (conn: ConnectionInfo): string => {
if (conn.login_credential_id) {
const credential = loginCredentials.value.find((item) => item.id === conn.login_credential_id);
if (credential) {
return `${t('connections.form.savedLoginCredential', '登录凭证')}: ${credential.name}`;
}
return `${t('connections.form.savedLoginCredential', '登录凭证')}: #${conn.login_credential_id}`;
}
return `${conn.username} ${conn.auth_method} ${conn.port}`;
};
const getConnectionEndpointTitle = (conn: ConnectionInfo): string => {
return `${conn.username}@${conn.host}:${conn.port}`;
};
const getConnectionRouteDisplay = (conn: ConnectionInfo): string => {
if (conn.proxy_type === 'proxy' && conn.proxy_id) {
const proxy = proxies.value.find((item) => item.id === conn.proxy_id);
if (proxy) {
return `${t('connections.proxyType', '代理')}: ${proxy.name}`;
}
return `${t('connections.proxyType', '代理')}: #${conn.proxy_id}`;
}
if (conn.proxy_type === 'jump' && conn.jump_chain?.length) {
const jumpNames = conn.jump_chain.map((jumpConnectionId) => {
const jumpConnection = connections.value.find((item) => item.id === jumpConnectionId);
return jumpConnection?.name || jumpConnection?.host || `#${jumpConnectionId}`;
});
return `${t('connections.form.connectionModeJumpHost', '跳板机')}: ${jumpNames.join(' -> ')}`;
}
return t('connections.noProxy', '未使用代理');
};
const getTagPathSegments = (tagName: string): string[] => {
return tagName
.split(tagPathSeparatorRegex)
@@ -635,6 +676,8 @@ const ensureDataLoaded = async () => {
}
await tagsStore.fetchTags();
await proxiesStore.fetchProxies();
await loginCredentialsStore.fetchLoginCredentials();
};
onMounted(async () => {
@@ -720,18 +763,6 @@ const clearTreeSearch = () => {
treeSearchQuery.value = '';
};
const setHoveredTreeNode = (nodeId: ScopeId | null) => {
hoveredTreeNodeId.value = nodeId;
};
const toggleTreeNodeFromAction = (node: TagTreeNode) => {
if (!node.expandable) {
return;
}
toggleTreeNode(node.id);
};
const startTreeDrag = (node: TagTreeNode) => {
draggingTreeNodeId.value = node.id;
dropTargetTreeNodeId.value = node.id;
@@ -1193,14 +1224,12 @@ onBeforeUnmount(() => {
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'group w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
'w-full flex items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getTreeNodeRowClass(node),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
draggable="true"
@mouseenter="setHoveredTreeNode(node.id)"
@mouseleave="setHoveredTreeNode(null)"
@dragstart="startTreeDrag(node)"
@dragenter.prevent="updateTreeDropTarget(node)"
@dragover.prevent
@@ -1229,36 +1258,6 @@ onBeforeUnmount(() => {
>
{{ node.count }}
</span>
<div
:class="[
'flex items-center gap-1 flex-shrink-0 transition-opacity duration-150',
hoveredTreeNodeId === node.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
]"
>
<button
v-if="node.expandable"
@click.stop="toggleTreeNodeFromAction(node)"
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="(expandedTreeNodes[node.id] ?? true) ? t('common.collapse', '收起') : t('common.expand', '展开')"
>
<i :class="['fas text-[11px]', (expandedTreeNodes[node.id] ?? true) ? 'fa-compress-alt' : 'fa-expand-alt']"></i>
</button>
<button
@click.stop="selectScope(node.id)"
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="t('connections.scopePinAction', '定位到此范围')"
>
<i class="fas fa-crosshairs text-[11px]"></i>
</button>
<button
@mousedown.stop
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center cursor-grab active:cursor-grabbing"
:title="t('connections.scopeDragAction', '拖拽重排(预留)')"
>
<i class="fas fa-grip-lines text-[11px]"></i>
</button>
</div>
</div>
</div>
</div>
@@ -1483,9 +1482,7 @@ onBeforeUnmount(() => {
</h3>
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-text-secondary">
<span>{{ conn.username }}</span>
<span>{{ conn.auth_method }}</span>
<span>{{ conn.port }}</span>
<span>{{ getConnectionCredentialDisplay(conn) }}</span>
<span>{{ t('connections.createdAt', '创建于') }} {{ formatRelativeTime(conn.created_at) }}</span>
</div>
</div>
@@ -1495,11 +1492,11 @@ onBeforeUnmount(() => {
<div class="text-sm font-medium text-foreground truncate" :title="conn.host">
{{ conn.host }}
</div>
<div class="mt-2 text-sm text-text-secondary truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
<div class="mt-2 text-sm text-text-secondary truncate" :title="getConnectionEndpointTitle(conn)">
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
</div>
<div class="mt-2 text-xs text-text-secondary">
{{ conn.proxy_type ? `${t('connections.proxyType', '代理')}: ${conn.proxy_type}` : t('connections.noProxy', '未使用代理') }}
{{ getConnectionRouteDisplay(conn) }}
</div>
</div>
+156 -27
View File
@@ -216,10 +216,59 @@
<ul class="list-none p-0 m-0">
<li
v-if="quickCommandContextTargetCommand"
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"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('runNow', quickCommandContextTargetCommand!)"
>
<i class="fas fa-play-circle w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.runNow', '立即执行') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('pasteToTerminal', quickCommandContextTargetCommand!)"
>
<i class="fas fa-terminal w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.pasteToTerminal', '粘贴到终端') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('copyCommand', quickCommandContextTargetCommand!)"
>
<i class="fas fa-copy w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.copyCommand', '复制命令') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('pasteToQuickInput', quickCommandContextTargetCommand!)"
>
<i class="fas fa-i-cursor w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.pasteToQuickInput', '粘贴到快捷输入框') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('sendToAllSessions', quickCommandContextTargetCommand!)"
>
<span>{{ t('quickCommands.actions.sendToAllSessions', '发送到全部会话') }}</span>
<i class="fas fa-server w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.sendToAllSessions', '发送到全部服务器') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1 mt-1 border-t border-border/50 pt-2"
@click="handleQuickCommandMenuAction('edit', quickCommandContextTargetCommand!)"
>
<i class="fas fa-pen w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.edit', '编辑') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-error hover:bg-error/10 hover:text-error text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('delete', quickCommandContextTargetCommand!)"
>
<i class="fas fa-trash-alt w-4 text-center text-error"></i>
<span>{{ t('quickCommands.actions.delete', '删除') }}</span>
</li>
</ul>
</div>
@@ -241,7 +290,9 @@ import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
import { resolveQuickCommandTemplate, type QuickCommandTemplateWarning } from '../utils/quickCommandTemplate';
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
@@ -253,6 +304,7 @@ const settingsStore = useSettingsStore();
const emitWorkspaceEvent = useWorkspaceEventEmitter();
const sessionStore = useSessionStore();
const connectionsStore = useConnectionsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const hoveredItemId = ref<number | null>(null);
const isFormVisible = ref(false);
@@ -270,6 +322,14 @@ const tagInputRefs = ref(new Map<string | number, HTMLInputElement | null>());
const quickCommandContextMenuVisible = ref(false);
const quickCommandContextMenuPosition = ref({ x: 0, y: 0 });
const quickCommandContextTargetCommand = ref<QuickCommandFE | null>(null);
type QuickCommandContextAction =
| 'runNow'
| 'pasteToTerminal'
| 'copyCommand'
| 'pasteToQuickInput'
| 'edit'
| 'delete'
| 'sendToAllSessions';
// --- Store Getter ---
const searchTerm = computed(() => quickCommandsStore.searchTerm);
@@ -552,46 +612,83 @@ const copyCommand = async (command: string) => {
}
};
//
const executeCommand = (cmd: QuickCommandFE) => {
// 1. 使
quickCommandsStore.incrementUsage(cmd.id);
let processedCommand = cmd.command;
const savedVariables = cmd.variables || {}; // 使
// 2.
for (const varName in savedVariables) {
const placeholder = new RegExp(`\\$\\{${varName}\\}`, 'g');
processedCommand = processedCommand.replace(placeholder, savedVariables[varName]);
const notifyTemplateWarnings = (undefinedVariables: string[], warnings: QuickCommandTemplateWarning[]) => {
if (undefinedVariables.length > 0) {
uiNotificationsStore.showWarning(
t('quickCommands.form.warningUndefinedVariables', { variables: undefinedVariables.join(', ') })
);
}
// 3.
const variablePlaceholders = cmd.command.match(/\$\{[^\}]+\}/g) || [];
const undefinedVariables: string[] = [];
variablePlaceholders.forEach(placeholder => {
const varName = placeholder.substring(2, placeholder.length - 1);
if (!savedVariables.hasOwnProperty(varName)) {
undefinedVariables.push(varName);
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 resolveProcessedCommand = async (cmd: QuickCommandFE, sessionId?: string | null) => {
const result = await resolveQuickCommandTemplate(cmd.command, {
customVariables: cmd.variables || {},
sessionId,
sessions: sessionStore.sessions,
connections: connectionsStore.connections,
fetchLoginCredentialDetails: loginCredentialsStore.fetchLoginCredentialDetails,
});
notifyTemplateWarnings(result.undefinedVariables, result.warnings);
return result.command;
};
// 4. SSH ID
const getActiveSessionIdOrNotify = () => {
const activeSessionId = sessionStore.activeSessionId;
if (!activeSessionId) {
uiNotificationsStore.showError(t('quickCommands.form.errorNoActiveSession', '没有活动的SSH会话可执行指令。'));
return null;
}
return activeSessionId;
};
//
const executeCommand = async (cmd: QuickCommandFE) => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
// 5. quickCommand:executeProcessed
void quickCommandsStore.incrementUsage(cmd.id);
const processedCommand = await resolveProcessedCommand(cmd, activeSessionId);
emitWorkspaceEvent('quickCommand:executeProcessed', {
command: processedCommand,
sessionId: activeSessionId
});
};
const pasteCommandToTerminalInput = async (cmd: QuickCommandFE) => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
sessionStore.updateSessionCommandInput(activeSessionId, await resolveProcessedCommand(cmd, activeSessionId));
uiNotificationsStore.showSuccess(t('quickCommands.notifications.pastedToTerminal', '已粘贴到终端输入框'));
};
const pasteCommandToQuickInput = async (cmd: QuickCommandFE) => {
const activeSessionId = sessionStore.activeSessionId;
quickCommandsStore.setSearchTerm(await resolveProcessedCommand(cmd, activeSessionId));
await nextTick();
if (searchInputRef.value) {
searchInputRef.value.focus();
searchInputRef.value.select();
}
uiNotificationsStore.showSuccess(t('quickCommands.notifications.pastedToQuickInput', '已粘贴到快捷输入框'));
};
// +++ +++
const focusSearchInput = (): boolean => {
if (searchInputRef.value) {
@@ -761,15 +858,47 @@ const closeQuickCommandContextMenu = () => {
document.removeEventListener('click', closeQuickCommandContextMenu);
};
const handleQuickCommandMenuAction = (action: 'sendToAllSessions', command: QuickCommandFE) => {
const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, command: QuickCommandFE) => {
closeQuickCommandContextMenu();
if (action === 'runNow') {
await executeCommand(command);
return;
}
if (action === 'pasteToTerminal') {
await pasteCommandToTerminalInput(command);
return;
}
if (action === 'copyCommand') {
void copyCommand(command.command);
return;
}
if (action === 'pasteToQuickInput') {
await pasteCommandToQuickInput(command);
return;
}
if (action === 'edit') {
openEditForm(command);
return;
}
if (action === 'delete') {
void confirmDelete(command);
return;
}
if (action === 'sendToAllSessions') {
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {
emitWorkspaceEvent('terminal:sendCommand', { sessionId: session.sessionId, command: command.command });
});
for (const session of activeSshSessions) {
const processedCommand = await resolveProcessedCommand(command, session.sessionId);
emitWorkspaceEvent('terminal:sendCommand', { sessionId: session.sessionId, command: processedCommand });
}
uiNotificationsStore.addNotification({
message: t('quickCommands.notifications.sentToAllSessions', { count: activeSshSessions.length }),
type: 'success',