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
+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',