This commit is contained in:
Baobhan Sith
2025-04-17 16:21:32 +08:00
parent 747c9491c4
commit 54ea8f34e3
14 changed files with 1648 additions and 483 deletions
+20 -1
View File
@@ -12,7 +12,8 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"pinia-plugin-persistedstate": "^4.2.0" "pinia-plugin-persistedstate": "^4.2.0",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -5154,6 +5155,12 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -6092,6 +6099,18 @@
"typescript": "*" "typescript": "*"
} }
}, },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+2 -1
View File
@@ -25,7 +25,8 @@
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme", "homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
"description": "", "description": "",
"dependencies": { "dependencies": {
"pinia-plugin-persistedstate": "^4.2.0" "pinia-plugin-persistedstate": "^4.2.0",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
+4
View File
@@ -195,6 +195,10 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
// 5. 设置 Shell 事件转发 // 5. 设置 Shell 事件转发
shellStream.on('data', (data: Buffer) => { shellStream.on('data', (data: Buffer) => {
// --- 添加日志:打印收到的原始数据 ---
console.log(`SSH Data (会话: ${newSessionId}, 原始): `, data.toString()); // 添加原始数据日志 (尝试 utf8)
console.log(`SSH Data (会话: ${newSessionId}, Hex): `, data.toString('hex')); // 添加 Hex 日志
// ------------------------------------
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' }));
} }
@@ -0,0 +1,465 @@
<script setup lang="ts">
import { ref, computed, watch, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLayoutStore, type LayoutNode, type PaneName } from '../stores/layout.store';
import draggable from 'vuedraggable';
import LayoutNodeEditor from './LayoutNodeEditor.vue'; // *** ***
// --- Props ---
const props = defineProps({
isVisible: {
type: Boolean,
required: true,
},
});
// --- Emits ---
const emit = defineEmits(['close']);
// --- Setup ---
const { t } = useI18n();
const layoutStore = useLayoutStore();
// --- State ---
// 便 store
const localLayoutTree: Ref<LayoutNode | null> = ref(null);
//
const hasChanges = ref(false);
// --- Watchers ---
// store
watch(() => props.isVisible, (newValue) => {
if (newValue && layoutStore.layoutTree) {
// store
localLayoutTree.value = JSON.parse(JSON.stringify(layoutStore.layoutTree));
hasChanges.value = false; //
console.log('[LayoutConfigurator] 弹窗打开,已加载当前布局到本地副本。');
} else {
localLayoutTree.value = null; //
}
});
//
watch(localLayoutTree, (newValue, oldValue) => {
// watch
if (oldValue !== null && props.isVisible) {
hasChanges.value = true;
console.log('[LayoutConfigurator] 本地布局已更改。');
}
}, { deep: true });
// --- Helper Function for Local Tree ---
// 使
function getLocalUsedPaneNames(node: LayoutNode | null): Set<PaneName> {
const usedNames = new Set<PaneName>();
if (!node) return usedNames;
function traverse(currentNode: LayoutNode) {
if (currentNode.type === 'pane' && currentNode.component) {
usedNames.add(currentNode.component);
} else if (currentNode.type === 'container' && currentNode.children) {
currentNode.children.forEach(traverse);
}
}
traverse(node);
return usedNames;
}
// --- Computed ---
// const availablePanes = computed(() => layoutStore.availablePanes); // store
const allPossiblePanes = computed(() => layoutStore.allPossiblePanes); //
// *** ***
const configuratorAvailablePanes = computed(() => {
const localUsed = getLocalUsedPaneNames(localLayoutTree.value);
return allPossiblePanes.value.filter(pane => !localUsed.has(pane));
});
// PaneName
const paneLabels = computed(() => ({
connections: t('layout.pane.connections', '连接列表'),
terminal: t('layout.pane.terminal', '终端'),
commandBar: t('layout.pane.commandBar', '命令栏'),
fileManager: t('layout.pane.fileManager', '文件管理器'),
editor: t('layout.pane.editor', '编辑器'),
statusMonitor: t('layout.pane.statusMonitor', '状态监视器'),
commandHistory: t('layout.pane.commandHistory', '命令历史'),
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
}));
// --- Methods ---
const closeDialog = () => {
if (hasChanges.value) {
if (confirm(t('layoutConfigurator.confirmClose', '有未保存的更改,确定要关闭吗?'))) {
emit('close');
}
} else {
emit('close');
}
};
const saveLayout = () => {
if (localLayoutTree.value) {
layoutStore.updateLayoutTree(localLayoutTree.value);
hasChanges.value = false;
console.log('[LayoutConfigurator] 布局已保存到 Store。');
emit('close'); //
} else {
console.error('[LayoutConfigurator] 无法保存,本地布局树为空。');
}
};
const resetToDefault = () => {
if (confirm(t('layoutConfigurator.confirmReset', '确定要恢复默认布局吗?当前更改将丢失。'))) {
// store
layoutStore.initializeLayout();
//
if (layoutStore.layoutTree) {
localLayoutTree.value = JSON.parse(JSON.stringify(layoutStore.layoutTree));
hasChanges.value = true; //
console.log('[LayoutConfigurator] 已重置为默认布局。');
}
}
};
// --- Drag & Drop Methods ---
// LayoutNode
const clonePane = (paneName: PaneName): LayoutNode => {
console.log(`[LayoutConfigurator] 克隆面板: ${paneName}`);
return {
id: layoutStore.generateId(), // 使 store ID
type: 'pane',
component: paneName,
size: 50, //
};
};
// handleDragStart
// const handleDragStart = (event: DragEvent, paneName: PaneName) => { ... }
// drop/dragover LayoutNodeEditor
// const handleDropOnPreview = (event: DragEvent) => { ... };
// const handleDragOverPreview = (event: DragEvent) => { ... };
// *** LayoutNodeEditor ***
const handleNodeUpdate = (updatedNode: LayoutNode) => {
// LayoutNodeEditor localLayoutTree
// v-model
//
// LayoutNodeEditor emit
//
// localLayoutTree
// vuedraggable v-model
// v-model
console.log('[LayoutConfigurator] Received node update:', updatedNode);
// v-model
localLayoutTree.value = updatedNode; //
};
// *** LayoutNodeEditor ***
//
function findAndRemoveNode(node: LayoutNode | null, parentNodeId: string | undefined, nodeIndex: number): LayoutNode | null {
if (!node) return null;
//
if (node.id === parentNodeId && node.type === 'container' && node.children && node.children[nodeIndex]) {
const updatedChildren = [...node.children];
updatedChildren.splice(nodeIndex, 1);
console.log(`[LayoutConfigurator] Removed node at index ${nodeIndex} from parent ${parentNodeId}`);
//
return { ...node, children: updatedChildren };
}
//
if (node.type === 'container' && node.children) {
const updatedChildren = node.children.map(child => findAndRemoveNode(child, parentNodeId, nodeIndex));
//
if (updatedChildren.some((child, index) => child !== node.children![index])) {
return { ...node, children: updatedChildren.filter(Boolean) as LayoutNode[] };
}
}
return node; //
}
const handleNodeRemove = (payload: { parentNodeId: string | undefined; nodeIndex: number }) => {
console.log('[LayoutConfigurator] Received node remove request:', payload);
if (payload.parentNodeId === undefined && payload.nodeIndex === 0) {
//
if (confirm('确定要清空整个布局吗?')) {
localLayoutTree.value = null; //
}
} else if (payload.parentNodeId) {
localLayoutTree.value = findAndRemoveNode(localLayoutTree.value, payload.parentNodeId, payload.nodeIndex);
} else {
console.warn('[LayoutConfigurator] Invalid remove payload:', payload);
}
};
</script>
<template>
<div v-if="isVisible" class="layout-configurator-overlay" @click.self="closeDialog">
<div class="layout-configurator-dialog">
<header class="dialog-header">
<h2>{{ t('layoutConfigurator.title', '配置工作区布局') }}</h2>
<button class="close-button" @click="closeDialog" :title="t('common.close', '关闭')">&times;</button>
</header>
<main class="dialog-content">
<section class="available-panes-section">
<h3>{{ t('layoutConfigurator.availablePanes', '可用面板') }}</h3>
<!-- *** 使用 draggable 包裹列表 *** -->
<draggable
:list="configuratorAvailablePanes"
tag="ul"
class="available-panes-list"
:item-key="(element: PaneName) => element"
:group="{ name: 'layout-items', pull: 'clone', put: false }"
:sort="false"
:clone="clonePane"
>
<template #item="{ element }: { element: PaneName }">
<li
class="available-pane-item"
>
<i class="fas fa-grip-vertical drag-handle"></i>
{{ paneLabels[element] || element }}
</li>
</template>
<template #footer>
<li v-if="configuratorAvailablePanes.length === 0" class="no-available-panes"> <!-- *** 使用新的计算属性 *** -->
{{ t('layoutConfigurator.noAvailablePanes', '所有面板都已在布局中') }}
</li>
</template>
</draggable>
</section>
<section
class="layout-preview-section"
>
<h3>{{ t('layoutConfigurator.layoutPreview', '布局预览(拖拽到此处)') }}</h3>
<div class="preview-area">
<!-- *** 使用 LayoutNodeEditor 渲染预览 *** -->
<LayoutNodeEditor
v-if="localLayoutTree"
:node="localLayoutTree"
:parent-node="null"
:node-index="0"
@update:node="handleNodeUpdate"
@removeNode="handleNodeRemove"
/>
<p v-else style="text-align: center; color: #aaa; margin-top: 50px;">
{{ t('layoutConfigurator.emptyLayout', '布局为空,请从左侧拖拽面板或添加容器。') }}
</p>
</div>
<div class="preview-actions">
<button @click="resetToDefault" class="button-secondary">
{{ t('layoutConfigurator.resetDefault', '恢复默认') }}
</button>
</div>
</section>
</main>
<footer class="dialog-footer">
<button @click="closeDialog" class="button-secondary">{{ t('common.cancel', '取消') }}</button>
<button @click="saveLayout" class="button-primary" :disabled="!hasChanges">
{{ t('common.save', '保存') }} {{ hasChanges ? '*' : '' }}
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.layout-configurator-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000; /* 低于 TerminalTabBar 的弹出窗口?可能需要调整 */
}
.layout-configurator-dialog {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
width: 80vw;
max-width: 900px; /* 增加最大宽度 */
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden; /* 防止内容溢出 */
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
background-color: #f8f9fa;
}
.dialog-header h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: #aaa;
line-height: 1;
padding: 0;
}
.close-button:hover {
color: #333;
}
.dialog-content {
flex-grow: 1;
padding: 1.5rem;
overflow-y: auto; /* 允许内容区滚动 */
display: flex; /* 左右布局 */
gap: 1.5rem;
}
.available-panes-section {
flex: 1; /* 占据一部分空间 */
min-width: 200px;
border-right: 1px solid #eee;
padding-right: 1.5rem;
}
.layout-preview-section {
flex: 2; /* 占据更多空间 */
display: flex;
flex-direction: column;
}
h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
font-weight: 600;
color: #495057;
}
.available-panes-list {
list-style: none;
padding: 0;
margin: 0;
}
.available-pane-item {
padding: 0.6rem 0.8rem;
margin-bottom: 0.5rem;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
cursor: grab;
display: flex;
align-items: center;
transition: background-color 0.2s ease;
}
.available-pane-item:hover {
background-color: #e9ecef;
}
.available-pane-item:active {
cursor: grabbing;
background-color: #ced4da;
}
.drag-handle {
margin-right: 0.5rem;
color: #adb5bd;
cursor: grab;
}
.available-pane-item:active .drag-handle {
cursor: grabbing;
}
.no-available-panes {
color: #6c757d;
font-style: italic;
padding: 0.5rem 0;
}
.preview-area {
flex-grow: 1;
border: 2px dashed #ced4da;
border-radius: 4px;
padding: 1rem;
background-color: #f8f9fa;
min-height: 300px; /* 保证预览区有一定高度 */
display: flex; /* 用于内部占位符居中 */
flex-direction: column;
/* justify-content: center; */ /* 移除,让内容从顶部开始 */
/* align-items: center; */ /* 移除 */
overflow: auto; /* 如果预览内容复杂,允许滚动 */
}
.preview-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.dialog-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.8rem;
background-color: #f8f9fa;
}
/* 通用按钮样式 */
.button-primary,
.button-secondary {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
.button-primary {
background-color: #007bff;
color: white;
}
.button-primary:hover {
background-color: #0056b3;
}
.button-primary:disabled {
background-color: #6c757d;
opacity: 0.7;
cursor: not-allowed;
}
.button-secondary {
background-color: #e9ecef;
color: #343a40;
border: 1px solid #ced4da;
}
.button-secondary:hover {
background-color: #dee2e6;
}
</style>
@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, type PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import draggable from 'vuedraggable';
import { useLayoutStore, type LayoutNode, type PaneName } from '../stores/layout.store';
// --- Props ---
const props = defineProps({
node: {
type: Object as PropType<LayoutNode>,
required: true,
},
//
parentNode: {
type: Object as PropType<LayoutNode | null>,
default: null,
},
// children
nodeIndex: {
type: Number,
default: -1,
},
});
// --- Emits ---
// LayoutConfigurator
const emit = defineEmits(['update:node', 'removeNode']);
// --- Setup ---
const { t } = useI18n();
const layoutStore = useLayoutStore();
// --- Computed ---
//
const paneLabels = computed(() => ({
connections: t('layout.pane.connections', '连接列表'),
terminal: t('layout.pane.terminal', '终端'),
commandBar: t('layout.pane.commandBar', '命令栏'),
fileManager: t('layout.pane.fileManager', '文件管理器'),
editor: t('layout.pane.editor', '编辑器'),
statusMonitor: t('layout.pane.statusMonitor', '状态监视器'),
commandHistory: t('layout.pane.commandHistory', '命令历史'),
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
}));
// v-model
// props vuedraggable list
// emit
const childrenList = computed({
get: () => props.node.children || [],
set: (newChildren) => {
// vuedraggable (props.node.children)
// emit
// emit('update:node', { ...props.node, children: newChildren });
// setter vuedraggable
console.log('[LayoutNodeEditor] childrenList setter called, relying on v-model/vuedraggable mutation.');
}
});
// --- Methods ---
//
const addHorizontalContainer = () => {
const newNode: LayoutNode = {
id: layoutStore.generateId(),
type: 'container',
direction: 'horizontal',
children: [], //
size: 50, //
};
const updatedChildren = [...(props.node.children || []), newNode];
emit('update:node', { ...props.node, children: updatedChildren });
};
//
const addVerticalContainer = () => {
const newNode: LayoutNode = {
id: layoutStore.generateId(),
type: 'container',
direction: 'vertical',
children: [],
size: 50,
};
const updatedChildren = [...(props.node.children || []), newNode];
emit('update:node', { ...props.node, children: updatedChildren });
};
//
const removeSelf = () => {
emit('removeNode', { parentNodeId: props.parentNode?.id, nodeIndex: props.nodeIndex });
};
//
const toggleDirection = () => {
if (props.node.type === 'container') {
const newDirection = props.node.direction === 'horizontal' ? 'vertical' : 'horizontal';
emit('update:node', { ...props.node, direction: newDirection });
}
};
//
const handleChildUpdate = (updatedChildNode: LayoutNode, index: number) => {
if (props.node.children) {
const newChildren = [...props.node.children];
newChildren[index] = updatedChildNode;
emit('update:node', { ...props.node, children: newChildren });
}
};
//
const handleChildRemove = (payload: { parentNodeId: string | undefined; nodeIndex: number }) => {
// LayoutConfigurator
console.log(`[LayoutNodeEditor ${props.node.id}] Relaying removeNode event upwards:`, payload); //
emit('removeNode', payload);
/*
//
if (payload.parentNodeId === props.node.id && props.node.children) {
const newChildren = [...props.node.children];
newChildren.splice(payload.nodeIndex, 1);
//
//
// emit update:node LayoutConfigurator
emit('update:node', { ...props.node, children: newChildren });
} else {
//
emit('removeNode', payload);
}
*/
};
</script>
<template>
<div
class="layout-node-editor"
:class="[`node-type-${node.type}`, node.direction ? `direction-${node.direction}` : '']"
:data-node-id="node.id"
>
<!-- 节点控制栏 -->
<div class="node-controls">
<span class="node-info">
{{ node.type === 'pane' ? (paneLabels[node.component!] || node.component) : `容器 (${node.direction === 'horizontal' ? '水平' : '垂直'})` }}
</span>
<div class="node-actions">
<button v-if="node.type === 'container'" @click="toggleDirection" title="切换方向" class="action-button">
<i class="fas fa-sync-alt"></i>
</button>
<button v-if="node.type === 'container'" @click="addHorizontalContainer" title="添加水平容器" class="action-button">
<i class="fas fa-columns"></i> H
</button>
<button v-if="node.type === 'container'" @click="addVerticalContainer" title="添加垂直容器" class="action-button">
<i class="fas fa-bars"></i> V
</button>
<button @click="removeSelf" title="移除此节点" class="action-button remove-button">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<!-- 如果是容器节点使用 draggable 渲染子节点 -->
<draggable
v-if="node.type === 'container'"
:list="childrenList"
@update:list="childrenList = $event"
tag="div"
class="node-children-container"
:class="[`children-direction-${node.direction}`]"
item-key="id"
group="layout-items"
handle=".drag-handle-node"
>
<template #item="{ element: childNode, index }">
<div class="child-node-wrapper" :key="childNode.id">
<i class="fas fa-grip-vertical drag-handle-node" title="拖拽调整顺序或移动"></i>
<LayoutNodeEditor
:node="childNode"
:parent-node="node"
:node-index="index"
@update:node="handleChildUpdate($event, index)"
@removeNode="handleChildRemove"
/>
</div>
</template>
<!-- 容器为空时的占位符 -->
<template #footer>
<div v-if="!childrenList || childrenList.length === 0" class="empty-container-placeholder">
将面板或容器拖拽到此处
</div>
</template>
</draggable>
<!-- 如果是面板节点只显示信息因为内容在主视图渲染 -->
<div v-else class="pane-node-content">
<!-- 面板节点在配置器中通常不需要显示内容 -->
</div>
</div>
</template>
<style scoped>
.layout-node-editor {
border: 1px solid #ccc;
margin: 5px;
padding: 5px;
position: relative;
background-color: #f9f9f9;
min-height: 60px; /* 保证有最小高度以便拖放 */
display: flex;
flex-direction: column;
}
.node-type-container {
background-color: #eef; /* 容器用淡蓝色背景 */
}
.node-type-pane {
background-color: #efe; /* 面板用淡绿色背景 */
}
.node-controls {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #eee;
padding: 3px 5px;
margin-bottom: 5px;
font-size: 0.8em;
min-height: 24px; /* 确保控制栏有高度 */
}
.node-info {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 10px;
}
.node-actions {
display: flex;
gap: 3px;
}
.action-button {
background: none;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
padding: 1px 4px;
font-size: 0.9em;
line-height: 1;
}
.action-button:hover {
background-color: #ddd;
}
.remove-button {
color: #dc3545;
border-color: #dc3545;
}
.remove-button:hover {
background-color: #dc3545;
color: white;
}
.node-children-container {
flex-grow: 1;
padding: 5px;
border: 1px dashed #bbb; /* 容器内部边框 */
min-height: 40px; /* 容器拖放区域最小高度 */
display: flex;
}
.children-direction-horizontal {
flex-direction: row;
}
.children-direction-vertical {
flex-direction: column;
}
.child-node-wrapper {
border: 1px solid transparent; /* 占位,防止抖动 */
position: relative; /* 用于定位拖拽句柄 */
display: flex; /* 让句柄和内容并排 */
align-items: stretch; /* 让子项高度一致 */
}
/* 根据方向调整子项的 flex 属性 */
.children-direction-horizontal > .child-node-wrapper {
flex: 1 1 auto; /* 水平方向允许伸缩 */
flex-direction: column; /* 内部还是列方向 */
}
.children-direction-vertical > .child-node-wrapper {
width: 100%; /* 垂直方向占满宽度 */
flex-direction: row; /* 内部行方向 */
align-items: center;
}
.drag-handle-node {
cursor: grab;
color: #aaa;
padding: 5px 3px; /* 增加点击区域 */
background-color: #f0f0f0;
border-right: 1px solid #ddd; /* 垂直句柄 */
}
.children-direction-vertical > .child-node-wrapper > .drag-handle-node {
border-right: none;
border-bottom: 1px solid #ddd; /* 水平句柄 */
writing-mode: vertical-rl; /* 可选:旋转图标 */
}
.child-node-wrapper > .layout-node-editor {
flex-grow: 1; /* 让递归组件填充剩余空间 */
margin: 0; /* 移除递归组件的外边距 */
border: none; /* 移除递归组件的边框 */
padding: 0; /* 移除递归组件的内边距 */
}
.pane-node-content {
/* 面板节点在配置器中通常是空的 */
min-height: 30px; /* 给面板一个最小高度 */
text-align: center;
color: #aaa;
font-size: 0.8em;
padding-top: 5px;
}
.empty-container-placeholder {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #ccc;
font-size: 0.9em;
min-height: 30px;
padding: 10px;
border: 1px dashed #ddd;
margin: 5px; /* 与 layout-node-editor 的 margin 匹配 */
}
/* vuedraggable 拖拽时的样式 */
.sortable-ghost {
opacity: 0.4;
background-color: #cceeff !important; /* 拖拽占位符样式 */
border: 1px dashed #007bff;
}
.sortable-chosen {
/* 被选中的元素样式 */
}
.sortable-drag {
/* 正在拖拽的元素样式 */
}
</style>
@@ -0,0 +1,350 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, type PropType, type Component } from 'vue';
import { Splitpanes, Pane } from 'splitpanes';
import 'splitpanes/dist/splitpanes.css'; // splitpanes
import { useLayoutStore, type LayoutNode, type PaneName } from '../stores/layout.store';
import { useSessionStore } from '../stores/session.store';
import { storeToRefs } from 'pinia';
import { defineEmits } from 'vue'; // *** defineEmits ***
// --- Props ---
const props = defineProps({
layoutNode: {
type: Object as PropType<LayoutNode>,
required: true,
},
//
activeSessionId: {
type: String as PropType<string | null>,
required: false, //
default: null, // null
},
// *** props ***
editorTabs: {
type: Array as PropType<any[]>, // 使 any[]
default: () => [],
},
activeEditorTabId: {
type: String as PropType<string | null>,
default: null,
},
});
// --- Emits ---
// *** (使) ***
const emit = defineEmits({
'sendCommand': null, // (command: string) - No validation needed here for now
'terminalInput': null, // (payload: { sessionId: string; data: string })
'terminalResize': null, // (payload: { sessionId: string; dims: { cols: number; rows: number } })
'closeEditorTab': null, // (tabId: string)
'activateEditorTab': null, // (tabId: string)
'updateEditorContent': null, // (payload: { tabId: string; content: string })
'saveEditorTab': null, // (tabId: string)
'connect-request': null, // (id: number)
'open-new-session': null, // (id: number)
'request-add-connection': null, // ()
'request-edit-connection': null, // (conn: any)
// *** terminal-ready payload ***
'terminal-ready': (payload: { sessionId: string; terminal: any }) => // 使 any Terminal
typeof payload === 'object' && typeof payload.sessionId === 'string' && typeof payload.terminal === 'object'
});
// --- Setup ---
const layoutStore = useLayoutStore();
const sessionStore = useSessionStore();
const { activeSession } = storeToRefs(sessionStore);
// --- Component Mapping ---
// 使 defineAsyncComponent PaneName
const componentMap: Record<PaneName, Component> = {
connections: defineAsyncComponent(() => import('./WorkspaceConnectionList.vue')),
terminal: defineAsyncComponent(() => import('./Terminal.vue')),
commandBar: defineAsyncComponent(() => import('./CommandInputBar.vue')),
fileManager: defineAsyncComponent(() => import('./FileManager.vue')),
editor: defineAsyncComponent(() => import('./FileEditorContainer.vue')),
statusMonitor: defineAsyncComponent(() => import('./StatusMonitor.vue')),
commandHistory: defineAsyncComponent(() => import('../views/CommandHistoryView.vue')),
quickCommands: defineAsyncComponent(() => import('../views/QuickCommandsView.vue')),
};
// --- Computed ---
//
const currentComponent = computed(() => {
if (props.layoutNode.type === 'pane' && props.layoutNode.component) {
return componentMap[props.layoutNode.component] || null;
}
return null;
});
// Props
// props
// Terminal, FileManager, StatusMonitor activeSession
// Editor
const componentProps = computed(() => {
const componentName = props.layoutNode.component;
const currentActiveSession = activeSession.value; //
if (!componentName) return {};
switch (componentName) {
// --- ---
case 'terminal':
// Terminal sessionId, isActive, ready, data, resize
// sessionId
return {
sessionId: props.activeSessionId ?? '', // activeSessionId null
isActive: true,
// *** ***
onReady: (payload: { sessionId: string; terminal: any }) => {
console.log(`[LayoutRenderer ${props.activeSessionId}] 收到内部 Terminal 的 'ready' 事件:`, payload); //
emit('terminal-ready', payload); // payload
},
onData: (data: string) => emit('terminalInput', { sessionId: props.activeSessionId ?? '', data }), // payload sessionId null
onResize: (dims: { cols: number; rows: number }) => emit('terminalResize', { sessionId: props.activeSessionId ?? '', dims }), // payload sessionId null
};
// --- onReady props ---
console.log(`[LayoutRenderer ${props.activeSessionId}] Terminal componentProps 计算完成,包含 onReady。`);
// -----------------------------------------
case 'fileManager':
// props
if (!currentActiveSession) return {};
return {
sessionId: props.activeSessionId ?? '', // sessionId null
dbConnectionId: currentActiveSession.connectionId,
sftpManager: currentActiveSession.sftpManager, // currentActiveSession null
wsDeps: { // wsDeps
sendMessage: currentActiveSession.wsManager.sendMessage,
onMessage: currentActiveSession.wsManager.onMessage,
isConnected: currentActiveSession.wsManager.isConnected,
isSftpReady: currentActiveSession.wsManager.isSftpReady
},
class: 'pane-content', // class
// FileManager
};
case 'statusMonitor':
// props
if (!currentActiveSession) return {};
return {
sessionId: props.activeSessionId ?? '', // sessionId null
serverStatus: currentActiveSession.statusMonitorManager.serverStatus.value, // currentActiveSession null
statusError: currentActiveSession.statusMonitorManager.statusError.value, // currentActiveSession null
class: 'pane-content',
};
case 'editor':
// FileEditorContainer tabs, activeTabId, sessionId,
return {
tabs: props.editorTabs, // WorkspaceView
activeTabId: props.activeEditorTabId, // WorkspaceView
sessionId: props.activeSessionId,
class: 'pane-content',
// ()
onCloseTab: (tabId: string) => emit('closeEditorTab', tabId),
onActivateTab: (tabId: string) => emit('activateEditorTab', tabId),
'onUpdate:content': (payload: { tabId: string; content: string }) => emit('updateEditorContent', payload), //
onRequestSave: (tabId: string) => emit('saveEditorTab', tabId),
};
case 'commandBar':
// CommandInputBar send-command
return {
class: 'pane-content',
onSendCommand: (command: string) => emit('sendCommand', command),
};
case 'connections':
// WorkspaceConnectionList connect-request
return {
class: 'pane-content',
// ( kebab-case)
onConnectRequest: (id: number) => emit('connect-request', id),
onOpenNewSession: (id: number) => emit('open-new-session', id),
onRequestAddConnection: () => emit('request-add-connection'),
onRequestEditConnection: (conn: any) => emit('request-edit-connection', conn), // 使 any
};
case 'commandHistory':
case 'quickCommands':
// execute-command
return {
class: 'pane-content',
onExecuteCommand: (command: string) => emit('sendCommand', command), // sendCommand
};
default:
return { class: 'pane-content' };
}
});
// --- Methods ---
// Splitpanes layoutStore size
// @resized panes
const handlePaneResize = (eventData: { panes: Array<{ size: number; [key: string]: any }> }) => {
console.log('Splitpanes resized event object:', eventData); //
const paneSizes = eventData.panes; // panes
console.log('Extracted paneSizes:', paneSizes); //
if (props.layoutNode.type === 'container' && props.layoutNode.children) {
// paneSizes
if (!Array.isArray(paneSizes)) {
console.error('[LayoutRenderer] handlePaneResize: 从事件对象提取的 panes 不是数组:', paneSizes);
return;
}
// store action
const childrenSizes = paneSizes.map((paneInfo, index) => ({
index: index,
size: paneInfo.size
}));
// store action
layoutStore.updateNodeSizes(props.layoutNode.id, childrenSizes);
}
};
</script>
<template>
<div class="layout-renderer" :data-node-id="layoutNode.id">
<!-- 如果是容器节点 -->
<template v-if="layoutNode.type === 'container' && layoutNode.children && layoutNode.children.length > 0">
<splitpanes
:horizontal="layoutNode.direction === 'vertical'"
class="default-theme"
style="height: 100%; width: 100%;"
@resized="handlePaneResize"
>
<pane
v-for="childNode in layoutNode.children"
:key="childNode.id"
:size="childNode.size ?? (100 / layoutNode.children.length)"
:min-size="5"
class="layout-pane-wrapper"
>
<!-- 递归调用自身来渲染子节点并转发所有必要的事件 -->
<LayoutRenderer
:layout-node="childNode"
:active-session-id="activeSessionId"
:editor-tabs="editorTabs"
:active-editor-tab-id="activeEditorTabId"
@send-command="emit('sendCommand', $event)"
@terminal-input="emit('terminalInput', $event)"
@terminal-resize="emit('terminalResize', $event)"
@terminal-ready="emit('terminal-ready', $event)"
@close-editor-tab="emit('closeEditorTab', $event)"
@activate-editor-tab="emit('activateEditorTab', $event)"
@update-editor-content="emit('updateEditorContent', $event)"
@save-editor-tab="emit('saveEditorTab', $event)"
@connect-request="emit('connect-request', $event)"
@open-new-session="emit('open-new-session', $event)"
@request-add-connection="emit('request-add-connection')"
@request-edit-connection="emit('request-edit-connection', $event)"
/>
</pane>
</splitpanes>
</template>
<!-- 如果是面板节点 -->
<template v-else-if="layoutNode.type === 'pane'">
<!-- Terminal 需要 keep-alive 处理 -->
<template v-if="layoutNode.component === 'terminal'">
<keep-alive>
<component
v-if="activeSession"
:is="currentComponent"
:key="activeSessionId"
v-bind="componentProps"
/>
</keep-alive>
<div v-if="!activeSession" class="pane-placeholder">无活动会话</div> <!-- 处理无活动会话的情况 -->
</template>
<!-- FileManager 需要 keep-alive 处理 -->
<template v-else-if="layoutNode.component === 'fileManager'">
<keep-alive>
<component
v-if="activeSession"
:is="currentComponent"
:key="activeSessionId"
v-bind="componentProps"
/>
</keep-alive>
<div v-if="!activeSession" class="pane-placeholder">无活动会话</div>
</template>
<!-- StatusMonitor 仅在有活动会话时渲染并添加 key ( keep-alive) -->
<template v-else-if="layoutNode.component === 'statusMonitor'">
<component
v-if="activeSession"
:is="currentComponent"
:key="activeSessionId"
v-bind="componentProps"
/>
<div v-else class="pane-placeholder">无活动会话</div>
</template>
<!-- 其他面板正常渲染 (不依赖 activeSession ) -->
<template v-else-if="currentComponent">
<component :is="currentComponent" v-bind="componentProps" />
</template>
<!-- 如果找不到组件 -->
<div v-else class="pane-placeholder error">
无效面板组件: {{ layoutNode.component || '未指定' }} (ID: {{ layoutNode.id }})
</div>
</template>
<!-- 如果节点类型未知或无效 -->
<template v-else>
<div class="pane-placeholder error">
无效布局节点 (ID: {{ layoutNode.id }})
</div>
</template>
</div>
</template>
<style scoped>
.layout-renderer {
height: 100%;
width: 100%;
overflow: hidden; /* 防止内部内容溢出渲染器边界 */
display: flex; /* 确保子元素能正确填充 */
flex-direction: column; /* 默认列方向 */
}
/* 为 splitpanes 包裹的 pane 添加样式,确保内容填充 */
.layout-pane-wrapper {
display: flex;
flex-direction: column;
overflow: hidden; /* 隐藏内部滚动条,由子组件处理 */
background-color: #f8f9fa; /* 默认背景,可能被子组件覆盖 */
}
/* 确保动态加载的组件能正确应用 pane-content 样式 */
/* 如果组件内部没有根元素应用 pane-content,可能需要在这里强制 */
:deep(.layout-pane-wrapper > *) {
flex-grow: 1;
overflow: auto; /* 或者 hidden */
display: flex;
flex-direction: column;
}
/* 特别是对于没有明确设置 class 的组件 */
:deep(.layout-pane-wrapper > .pane-placeholder) {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #adb5bd;
background-color: #f8f9fa;
font-size: 0.9em;
padding: 1rem;
}
:deep(.layout-pane-wrapper > .pane-placeholder.error) {
color: #dc3545; /* 错误用红色 */
background-color: #fdd;
}
/* Splitpanes 默认主题样式调整 (如果需要覆盖全局样式) */
/* :deep(.splitpanes.default-theme .splitpanes__splitter) { */
/* background-color: #ccc; */
/* } */
/* :deep(.splitpanes--vertical > .splitpanes__splitter) { */
/* width: 7px; */
/* } */
/* :deep(.splitpanes--horizontal > .splitpanes__splitter) { */
/* height: 7px; */
/* } */
</style>
@@ -17,7 +17,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'data', data: string): void; // (e: 'data', data: string): void; //
(e: 'resize', dimensions: { cols: number; rows: number }): void; // (e: 'resize', dimensions: { cols: number; rows: number }): void; //
(e: 'ready', terminal: Terminal): void; // (e: 'ready', payload: { sessionId: string; terminal: Terminal }): void; // *** ready sessionId terminal ***
}>(); }>();
const terminalRef = ref<HTMLElement | null>(null); // const terminalRef = ref<HTMLElement | null>(null); //
@@ -185,8 +185,10 @@ onMounted(() => {
} }
}, { immediate: true }); // watch }, { immediate: true }); // watch
// ready // ready sessionId terminal
emit('ready', terminal); if (terminal) { // terminal
emit('ready', { sessionId: props.sessionId, terminal: terminal });
}
// //
terminal.focus(); terminal.focus();
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, PropType } from 'vue'; // ref computed import { ref, computed, PropType } from 'vue'; // ref computed
import { useI18n } from 'vue-i18n'; // i18n import { useI18n } from 'vue-i18n'; // i18n
import { storeToRefs } from 'pinia'; // storeToRefs // import { storeToRefs } from 'pinia'; // storeToRefs paneVisibility 使
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue'; // import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue'; //
import { useSessionStore } from '../stores/session.store'; // session store import { useSessionStore } from '../stores/session.store'; // session store
import { useLayoutStore, type PaneName } from '../stores/layout.store'; // store import { useLayoutStore, type PaneName } from '../stores/layout.store'; // store
@@ -11,7 +11,7 @@ import type { SessionTabInfoWithStatus } from '../stores/session.store'; // 导
// --- Setup --- // --- Setup ---
const { t } = useI18n(); // i18n const { t } = useI18n(); // i18n
const layoutStore = useLayoutStore(); // store const layoutStore = useLayoutStore(); // store
const { paneVisibility } = storeToRefs(layoutStore); // 使 storeToRefs // const { paneVisibility } = storeToRefs(layoutStore); // paneVisibility
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -20,13 +20,14 @@ const props = defineProps({
required: true, required: true,
}, },
activeSessionId: { activeSessionId: {
type: String as PropType<string | null>, type: String as PropType<string | null>, // null
required: true, required: false, // null
default: null, // null
}, },
}); });
// //
const emit = defineEmits(['activate-session', 'close-session']); const emit = defineEmits(['activate-session', 'close-session', 'open-layout-configurator']); //
const activateSession = (sessionId: string) => { const activateSession = (sessionId: string) => {
if (sessionId !== props.activeSessionId) { if (sessionId !== props.activeSessionId) {
@@ -63,25 +64,32 @@ const toggleLayoutMenu = () => {
console.log('New state:', showLayoutMenu.value); // console.log('New state:', showLayoutMenu.value); //
}; };
// ( commandBar) //
const openLayoutConfigurator = () => {
console.log('[TabBar] Emitting open-layout-configurator event');
emit('open-layout-configurator'); //
};
// --- () ---
// ()
const paneLabels: Record<PaneName, string> = { const paneLabels: Record<PaneName, string> = {
connections: t('layout.pane.connections'), connections: t('layout.pane.connections'),
terminal: t('layout.pane.terminal'), terminal: t('layout.pane.terminal'),
commandBar: t('layout.pane.commandBar'), // commandBar: t('layout.pane.commandBar'),
fileManager: t('layout.pane.fileManager'), fileManager: t('layout.pane.fileManager'),
editor: t('layout.pane.editor'), editor: t('layout.pane.editor'),
statusMonitor: t('layout.pane.statusMonitor'), statusMonitor: t('layout.pane.statusMonitor'),
commandHistory: t('layout.pane.commandHistory', '命令历史'), commandHistory: t('layout.pane.commandHistory', '命令历史'),
quickCommands: t('layout.pane.quickCommands', '快捷指令'), // quickCommands: t('layout.pane.quickCommands', '快捷指令'),
}; };
// // ()
const availablePanes = computed(() => Object.keys(paneVisibility.value) as PaneName[]); // 使 .value const allPanesForMenu = computed(() => layoutStore.allPossiblePanes); // 使 allPossiblePanes
// // ()
const handleTogglePane = (paneName: PaneName) => { const handleTogglePane = (paneName: PaneName) => {
layoutStore.togglePaneVisibility(paneName); console.warn(`[TabBar] 旧的 handleTogglePane 被调用,但 togglePaneVisibility 已移除。面板: ${paneName}`);
// // layoutStore.togglePaneVisibility(paneName); //
// showLayoutMenu.value = false; // showLayoutMenu.value = false;
}; };
@@ -111,22 +119,30 @@ const handleTogglePane = (paneName: PaneName) => {
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
</div> </div>
<!-- 布局菜单按钮容器推到最右侧 --> <!-- 按钮容器推到最右侧 -->
<div class="layout-menu-container"> <div class="action-buttons-container">
<button class="layout-menu-button" @click="toggleLayoutMenu" title="调整布局"> <!-- 新增布局配置器按钮 -->
<i class="fas fa-bars"></i> <!-- 使用 Font Awesome bars 图标 --> <button class="layout-config-button" @click="openLayoutConfigurator" title="配置工作区布局">
<i class="fas fa-th-large"></i> <!-- 网格布局图标 -->
</button> </button>
<!-- 布局菜单下拉列表 (保持不变) --> <!-- 保留旧的布局菜单按钮 -->
<div v-if="showLayoutMenu" class="layout-menu-dropdown"> <div class="layout-menu-container">
<ul> <button class="layout-menu-button" @click="toggleLayoutMenu" title="切换面板可见性 (旧)">
<li v-for="pane in availablePanes" :key="pane" @click="handleTogglePane(pane)"> <i class="fas fa-bars"></i> <!-- 汉堡菜单图标 -->
<span class="checkmark">{{ paneVisibility[pane] ? '✓' : '' }}</span> </button>
{{ paneLabels[pane] || pane }} <!-- 旧布局菜单下拉列表 (显示所有面板但勾选状态和点击功能失效) -->
</li> <div v-if="showLayoutMenu" class="layout-menu-dropdown">
</ul> <ul>
<!-- 使用 allPanesForMenu 迭代 -->
<li v-for="pane in allPanesForMenu" :key="pane" @click="handleTogglePane(pane)">
<!-- 移除基于 paneVisibility 的勾选 -->
<span class="checkmark"></span> <!-- 占位符保持对齐 -->
{{ paneLabels[pane] || pane }}
</li>
</ul>
</div>
</div> </div>
</div> </div>
<!-- 移除多余的结束标签 -->
<!-- 连接列表弹出窗口 (保持不变) --> <!-- 连接列表弹出窗口 (保持不变) -->
<div v-if="showConnectionListPopup" class="connection-list-popup" @click.self="togglePopup"> <div v-if="showConnectionListPopup" class="connection-list-popup" @click.self="togglePopup">
<div class="popup-content"> <div class="popup-content">
@@ -280,7 +296,37 @@ const handleTogglePane = (paneName: PaneName) => {
line-height: 1; /* 确保图标垂直居中 */ line-height: 1; /* 确保图标垂直居中 */
} }
/* 移除 action-buttons-container 样式 */ /* 新增:包裹右侧操作按钮的容器 */
.action-buttons-container {
display: flex;
align-items: center;
margin-left: auto; /* 将整个容器推到右侧 */
height: 100%;
flex-shrink: 0; /* 防止被压缩 */
}
/* 新增:布局配置器按钮样式 */
.layout-config-button {
background: none;
border: none;
border-left: 1px solid #bdbdbd; /* 左侧分隔线 */
padding: 0 0.8rem;
cursor: pointer;
font-size: 1.1em; /* 与其他按钮一致 */
color: #616161;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex-shrink: 0;
}
.layout-config-button:hover {
background-color: #d0d0d0;
}
.layout-config-button i {
line-height: 1;
}
/* 弹出窗口样式 */ /* 弹出窗口样式 */
.connection-list-popup { .connection-list-popup {
@@ -346,17 +392,18 @@ const handleTogglePane = (paneName: PaneName) => {
padding: 0; /* 保持移除内边距 */ padding: 0; /* 保持移除内边距 */
} }
/* 新增:布局菜单样式 */ /* 调整:旧布局菜单容器样式 */
.layout-menu-container { .layout-menu-container {
position: relative; /* 用于定位下拉菜单 */ position: relative; /* 用于定位下拉菜单 */
display: flex; /* 确保按钮垂直居中 */ display: flex; /* 确保按钮垂直居中 */
align-items: center; align-items: center;
height: 100%; height: 100%;
margin-left: auto; /* 保持:将布局按钮推到最右侧 */ /* margin-left: auto; */ /* 移除:由父容器 .action-buttons-container 控制 */
border-left: 1px solid #bdbdbd; /* 确保布局按钮左侧分隔线 */ border-left: 1px solid #bdbdbd; /* 保持左侧分隔线 */
flex-shrink: 0; /* 保持:防止被压缩 */ flex-shrink: 0; /* 保持:防止被压缩 */
} }
/* 调整:旧布局菜单按钮样式 */
.layout-menu-button { .layout-menu-button {
background: none; background: none;
border: none; border: none;
@@ -38,8 +38,18 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
const handleTerminalReady = (term: Terminal) => { const handleTerminalReady = (term: Terminal) => {
console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`); console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`);
terminalInstance.value = term; terminalInstance.value = term;
// --- 添加日志:检查缓冲区处理 ---
console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 准备处理缓冲区,缓冲区长度: ${terminalOutputBuffer.value.length}`);
if (terminalOutputBuffer.value.length > 0) {
console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 缓冲区内容 (前100字符):`, terminalOutputBuffer.value.map(d => d.substring(0, 100)).join(' | '));
}
// ---------------------------------
// 将缓冲区的输出写入终端 // 将缓冲区的输出写入终端
terminalOutputBuffer.value.forEach(data => term.write(data)); terminalOutputBuffer.value.forEach(data => {
console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 正在写入缓冲数据 (前100字符):`, data.substring(0, 100));
term.write(data);
});
console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 缓冲区处理完成。`);
terminalOutputBuffer.value = []; // 清空缓冲区 terminalOutputBuffer.value = []; // 清空缓冲区
// 可以在这里自动聚焦或执行其他初始化操作 // 可以在这里自动聚焦或执行其他初始化操作
// term.focus(); // 也许在 ssh:connected 时聚焦更好 // term.focus(); // 也许在 ssh:connected 时聚焦更好
@@ -166,11 +176,18 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
} }
} }
// --- 添加前端日志 ---
console.log(`[会话 ${sessionId}][SSH前端] 收到 ssh:output 原始 payload (解码前):`, payload);
console.log(`[会话 ${sessionId}][SSH前端] 解码后的数据 (尝试写入):`, outputData);
// --------------------
if (terminalInstance.value) { if (terminalInstance.value) {
console.log(`[会话 ${sessionId}][SSH前端] 终端实例存在,尝试写入...`);
terminalInstance.value.write(outputData); terminalInstance.value.write(outputData);
console.log(`[会话 ${sessionId}][SSH前端] 写入完成。`);
} else { } else {
// 如果终端还没准备好,先缓冲输出 // 如果终端还没准备好,先缓冲输出
console.log(`[会话 ${sessionId}][SSH前端] 终端实例不存在,缓冲数据...`);
terminalOutputBuffer.value.push(outputData); terminalOutputBuffer.value.push(outputData);
} }
}; };
+8 -1
View File
@@ -492,7 +492,14 @@
"disabled": "Disabled", "disabled": "Disabled",
"settings": "Settings", "settings": "Settings",
"errorOccurred": "An error occurred.", "errorOccurred": "An error occurred.",
"dismiss": "Dismiss" "dismiss": "Dismiss",
"close": "Close"
},
"layoutConfigurator": {
"title": "Layout Configurator",
"availablePanes": "Available Panes",
"layoutPreview": "Layout Preview",
"resetDefault": "Reset to Default Layout"
}, },
"auditLog": { "auditLog": {
"title": "Audit Logs", "title": "Audit Logs",
+10 -2
View File
@@ -495,7 +495,14 @@
"disabled": "已禁用", "disabled": "已禁用",
"settings": "设置", "settings": "设置",
"errorOccurred": "发生错误。", "errorOccurred": "发生错误。",
"dismiss": "关闭" "dismiss": "关闭",
"close": "关闭"
},
"layoutConfigurator": {
"title": "布局配置器",
"availablePanes": "可用面板",
"layoutPreview": "布局预览",
"resetDefault": "重置为默认布局"
}, },
"auditLog": { "auditLog": {
"title": "审计日志", "title": "审计日志",
@@ -559,7 +566,8 @@
"fileManager": "文件管理器", "fileManager": "文件管理器",
"editor": "编辑器", "editor": "编辑器",
"statusMonitor": "状态监视器", "statusMonitor": "状态监视器",
"commandHistory": "命令历史" "commandHistory": "命令历史",
"quickCommands": "快捷指令"
} }
}, },
"commandHistory": { "commandHistory": {
+194 -28
View File
@@ -1,46 +1,212 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref, computed, watch, type Ref, type ComputedRef } from 'vue';
// 定义面板名称的类型,方便管理和引用 (添加 quickCommands) // 定义所有可用面板名称
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands'; export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands';
// 定义布局节点接口
export interface LayoutNode {
id: string; // 唯一 ID
type: 'pane' | 'container'; // 节点类型:面板或容器
component?: PaneName; // 如果 type 是 'pane',指定要渲染的组件
direction?: 'horizontal' | 'vertical'; // 如果 type 是 'container',指定分割方向
children?: LayoutNode[]; // 如果 type 是 'container',包含子节点数组
size?: number; // 节点在父容器中的大小比例 (例如 20, 50, 30)
}
// 本地存储的 Key
const LAYOUT_STORAGE_KEY = 'nexus_terminal_layout_config';
// 生成唯一 ID 的辅助函数
function generateId(): string {
// 简单实现,实际项目中可能使用更健壮的库如 uuid
return Math.random().toString(36).substring(2, 15);
}
// 定义默认布局结构
const getDefaultLayout = (): LayoutNode => ({
id: generateId(), // 根容器 ID
type: 'container',
direction: 'horizontal', // 主方向:水平分割
children: [
// 左侧边栏:连接列表
{ id: generateId(), type: 'pane', component: 'connections', size: 15 },
// 中间主区域:垂直分割
{
id: generateId(),
type: 'container',
direction: 'vertical',
size: 60, // 中间区域占比
children: [
// 上方:终端
{ id: generateId(), type: 'pane', component: 'terminal', size: 60 },
// 中下方:命令栏 (固定高度,特殊处理或放在终端内?) - 暂时移除,可在配置器中添加
// { id: generateId(), type: 'pane', component: 'commandBar', size: 5 },
// 下方:文件管理器
{ id: generateId(), type: 'pane', component: 'fileManager', size: 40 },
],
},
// 右侧边栏:垂直分割
{
id: generateId(),
type: 'container',
direction: 'vertical',
size: 25, // 右侧区域占比
children: [
// 上方:编辑器
{ id: generateId(), type: 'pane', component: 'editor', size: 60 },
// 下方:状态监视器
{ id: generateId(), type: 'pane', component: 'statusMonitor', size: 40 },
// 可选:命令历史和快捷指令可以放在这里,或者作为可添加的面板
// { id: generateId(), type: 'pane', component: 'commandHistory', size: 20 },
// { id: generateId(), type: 'pane', component: 'quickCommands', size: 20 },
],
},
],
});
// 递归查找布局树中所有使用的面板组件名称
function getUsedPaneNames(node: LayoutNode | null): Set<PaneName> {
const usedNames = new Set<PaneName>();
if (!node) return usedNames;
function traverse(currentNode: LayoutNode) {
if (currentNode.type === 'pane' && currentNode.component) {
usedNames.add(currentNode.component);
} else if (currentNode.type === 'container' && currentNode.children) {
currentNode.children.forEach(traverse);
}
}
traverse(node);
return usedNames;
}
// 定义 Store // 定义 Store
export const useLayoutStore = defineStore('layout', () => { export const useLayoutStore = defineStore('layout', () => {
// 使用 ref 创建响应式状态,存储每个面板的可见性 (添加 commandHistory) // --- 状态 ---
const paneVisibility = ref<Record<PaneName, boolean>>({ // 核心状态:存储当前布局树结构
connections: true, const layoutTree: Ref<LayoutNode | null> = ref(null);
terminal: true, // 存储所有理论上可用的面板名称
commandBar: true, const allPossiblePanes: Ref<PaneName[]> = ref([
fileManager: true, 'connections', 'terminal', 'commandBar', 'fileManager',
editor: true, 'editor', 'statusMonitor', 'commandHistory', 'quickCommands'
statusMonitor: true, ]);
commandHistory: true,
quickCommands: true, // 默认可见 // --- 计算属性 ---
// 计算当前布局中正在使用的面板
const usedPanes: ComputedRef<Set<PaneName>> = computed(() => getUsedPaneNames(layoutTree.value));
// 计算当前未在布局中使用的面板(可用于配置器中添加)
const availablePanes: ComputedRef<PaneName[]> = computed(() => {
const used = usedPanes.value;
return allPossiblePanes.value.filter(pane => !used.has(pane));
}); });
// Action: 切换指定面板的可见性 // --- Actions ---
function togglePaneVisibility(paneName: PaneName) { // 初始化布局:尝试从 localStorage 加载,否则使用默认布局
if (paneVisibility.value[paneName] !== undefined) { function initializeLayout() {
paneVisibility.value[paneName] = !paneVisibility.value[paneName]; try {
console.log(`[Layout Store] Toggled visibility for ${paneName}: ${paneVisibility.value[paneName]}`); const savedLayout = localStorage.getItem(LAYOUT_STORAGE_KEY);
} else { if (savedLayout) {
console.warn(`[Layout Store] Attempted to toggle visibility for unknown pane: ${paneName}`); const parsedLayout = JSON.parse(savedLayout) as LayoutNode;
// 可选:添加验证逻辑确保加载的布局结构有效
layoutTree.value = parsedLayout;
console.log('[Layout Store] 从 localStorage 加载布局成功。');
} else {
layoutTree.value = getDefaultLayout();
console.log('[Layout Store] 未找到保存的布局,使用默认布局。');
}
} catch (error) {
console.error('[Layout Store] 加载或解析布局失败:', error);
layoutTree.value = getDefaultLayout(); // 出错时回退到默认布局
} }
} }
// Action: 设置指定面板的可见性 // 更新整个布局树(通常由配置器保存时调用)
function setPaneVisibility(paneName: PaneName, isVisible: boolean) { function updateLayoutTree(newTree: LayoutNode) {
if (paneVisibility.value[paneName] !== undefined) { // 可选:添加验证逻辑
paneVisibility.value[paneName] = isVisible; layoutTree.value = newTree;
console.log(`[Layout Store] Set visibility for ${paneName} to: ${isVisible}`); console.log('[Layout Store] 布局树已更新。');
// 保存将在 watch 中自动触发
}
// 新增:递归查找并更新节点大小
function findAndUpdateNodeSize(node: LayoutNode | null, nodeId: string, childrenSizes: { index: number; size: number }[]): LayoutNode | null {
if (!node) return null;
if (node.id === nodeId && node.type === 'container' && node.children) {
const updatedChildren = [...node.children];
childrenSizes.forEach(({ index, size }) => {
if (updatedChildren[index]) {
updatedChildren[index] = { ...updatedChildren[index], size: size };
}
});
return { ...node, children: updatedChildren };
}
if (node.type === 'container' && node.children) {
const updatedChildren = node.children.map(child => findAndUpdateNodeSize(child, nodeId, childrenSizes));
// 检查是否有子节点被更新
if (updatedChildren.some((child, index) => child !== node.children![index])) {
return { ...node, children: updatedChildren.filter(Boolean) as LayoutNode[] };
}
}
return node; // 未找到或未更新,返回原节点
}
// 新增 Action: 更新特定容器节点的子节点大小
function updateNodeSizes(nodeId: string, childrenSizes: { index: number; size: number }[]) {
console.log(`[Layout Store] 请求更新节点 ${nodeId} 的子节点大小:`, childrenSizes);
const updatedTree = findAndUpdateNodeSize(layoutTree.value, nodeId, childrenSizes);
if (updatedTree && updatedTree !== layoutTree.value) {
// 只有在树实际发生变化时才更新 ref 以触发 watch
layoutTree.value = updatedTree;
console.log(`[Layout Store] 节点 ${nodeId} 的子节点大小已更新。`);
} else if (updatedTree === layoutTree.value) {
console.log(`[Layout Store] 未找到节点 ${nodeId} 或大小未改变。`);
} else { } else {
console.warn(`[Layout Store] Attempted to set visibility for unknown pane: ${paneName}`); console.error(`[Layout Store] 更新节点 ${nodeId} 大小后得到无效的树结构。`);
} }
} }
// --- 持久化 ---
// 监听 layoutTree 的变化,并自动保存到 localStorage
watch(
layoutTree,
(newTree) => {
if (newTree) {
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(newTree));
console.log('[Layout Store] 布局已自动保存到 localStorage。');
} catch (error) {
console.error('[Layout Store] 保存布局到 localStorage 失败:', error);
}
} else {
// 如果布局被清空,也移除本地存储
localStorage.removeItem(LAYOUT_STORAGE_KEY);
console.log('[Layout Store] 布局为空,已从 localStorage 移除。');
}
},
{ deep: true } // 需要深度监听来捕获嵌套结构的变化
);
// --- 初始化 ---
// Store 创建时自动初始化布局
initializeLayout();
// --- 返回 ---
return { return {
paneVisibility, layoutTree,
togglePaneVisibility, availablePanes, // 供配置器使用
setPaneVisibility, usedPanes, // 可用于调试或内部逻辑
updateLayoutTree,
initializeLayout, // 允许外部重置或重新加载
updateNodeSizes, // *** 新增:暴露更新大小的 action ***
// 暴露 generateId 供配置器使用(如果需要)
generateId,
// 暴露 allPossiblePanes 供配置器显示所有选项
allPossiblePanes,
}; };
}); });
+19 -12
View File
@@ -352,18 +352,25 @@ export const useSessionStore = defineStore('session', () => {
// 不需要再调用 activateSession,因为它已经是活动的 // 不需要再调用 activateSession,因为它已经是活动的
} else { } else {
// 如果状态正常,则无需操作 // 如果状态正常,则无需操作
console.log(`[SessionStore] 活动会话 ${existingSessionId} 状态正常,无需操作。`); console.log(`[SessionStore] 活动会话 ${existingSessionId} 状态正常,无需操作。`);
} }
} else { } else if (existingSessionId && existingSession) {
// 点击的不是当前活动标签(可能是非活动标签,或根本不存在),总是新建标签页 // 点击的是一个已存在但非活动的会话
if (existingSessionId) { console.log(`[SessionStore] 点击的连接 ${connIdStr} 存在于非活动会话 ${existingSessionId} 中,将激活它。`);
console.log(`[SessionStore] 点击的连接 ${connIdStr} 存在于非活动会话 ${existingSessionId} 中,将打开新会话。`); activateSession(existingSessionId);
} else { // 激活后检查状态并尝试重连 (如果需要)
console.log(`[SessionStore] 未找到 ID 为 ${connIdStr} 的现有会话,将打开新会话。`); const currentStatus = existingSession.wsManager.connectionStatus.value;
} if (currentStatus === 'disconnected' || currentStatus === 'error') {
openNewSession(connIdStr); // 直接调用 openNewSession console.log(`[SessionStore] 激活的会话 ${existingSessionId} 已断开或出错,尝试重连...`);
} const wsUrl = `ws://${window.location.hostname}:3001`; // TODO: 从配置获取 URL
}; existingSession.wsManager.connect(wsUrl);
}
} else {
// 点击的连接没有对应的会话,创建新会话
console.log(`[SessionStore] 未找到 ID 为 ${connIdStr} 的现有会话,将打开新会话。`);
openNewSession(connIdStr);
}
};
/** /**
* *
+123 -404
View File
@@ -2,61 +2,48 @@
import { onMounted, onBeforeUnmount, computed, ref } from 'vue'; import { onMounted, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import TerminalComponent from '../components/Terminal.vue'; // 使
import FileManagerComponent from '../components/FileManager.vue';
import StatusMonitorComponent from '../components/StatusMonitor.vue';
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; import AddConnectionFormComponent from '../components/AddConnectionForm.vue';
import TerminalTabBar from '../components/TerminalTabBar.vue'; import TerminalTabBar from '../components/TerminalTabBar.vue';
import CommandInputBar from '../components/CommandInputBar.vue'; import LayoutRenderer from '../components/LayoutRenderer.vue'; // *** ***
import FileEditorContainer from '../components/FileEditorContainer.vue'; // import LayoutConfigurator from '../components/LayoutConfigurator.vue'; // *** ***
import CommandHistoryView from './CommandHistoryView.vue'; // import { useSessionStore, type SessionTabInfoWithStatus, type SshTerminalInstance } from '../stores/session.store';
import QuickCommandsView from './QuickCommandsView.vue'; // import { useSettingsStore } from '../stores/settings.store';
import PaneTitleBar from '../components/PaneTitleBar.vue'; // import { useFileEditorStore } from '../stores/fileEditor.store';
import { useSessionStore, type SessionTabInfoWithStatus, type SshTerminalInstance } from '../stores/session.store'; // SshTerminalInstance import { useLayoutStore } from '../stores/layout.store';
import { useSettingsStore } from '../stores/settings.store'; // Store import { useCommandHistoryStore } from '../stores/commandHistory.store';
import { useFileEditorStore } from '../stores/fileEditor.store'; // Store
import { useLayoutStore } from '../stores/layout.store'; // Store
import { useCommandHistoryStore } from '../stores/commandHistory.store'; // Store
import type { ConnectionInfo } from '../stores/connections.store'; import type { ConnectionInfo } from '../stores/connections.store';
// splitpanes import type { Terminal } from 'xterm'; // *** Terminal ***
import { Splitpanes, Pane } from 'splitpanes';
// FileManagerComponent prop
// import type { SftpManagerInstance } from '../stores/session.store'; // SftpManagerInstance
// --- Setup --- // --- Setup ---
const { t } = useI18n(); const { t } = useI18n();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const settingsStore = useSettingsStore(); // Store const settingsStore = useSettingsStore();
const fileEditorStore = useFileEditorStore(); // Store () const fileEditorStore = useFileEditorStore();
const layoutStore = useLayoutStore(); // Store const layoutStore = useLayoutStore();
const commandHistoryStore = useCommandHistoryStore(); // Store const commandHistoryStore = useCommandHistoryStore();
// --- Store Getters --- // --- Store Getters ---
const { sessionTabsWithStatus, activeSessionId, activeSession } = storeToRefs(sessionStore); const { sessionTabsWithStatus, activeSessionId, activeSession } = storeToRefs(sessionStore);
const { shareFileEditorTabsBoolean } = storeToRefs(settingsStore); // const { shareFileEditorTabsBoolean } = storeToRefs(settingsStore);
const { orderedTabs: globalEditorTabs, activeTabId: globalActiveEditorTabId } = storeToRefs(fileEditorStore); // const { orderedTabs: globalEditorTabs, activeTabId: globalActiveEditorTabId } = storeToRefs(fileEditorStore);
const { paneVisibility } = storeToRefs(layoutStore); // const { layoutTree } = storeToRefs(layoutStore); //
// --- ( Props) --- // --- ( Props) ---
// **** // LayoutRenderer
const editorTabs = computed(() => { const editorTabs = computed(() => {
if (shareFileEditorTabsBoolean.value) { if (shareFileEditorTabsBoolean.value) {
// console.log('[WorkspaceView] Shared Mode: Returning globalEditorTabs'); return globalEditorTabs.value;
return globalEditorTabs.value; // store tabs
} else { } else {
// console.log('[WorkspaceView] Independent Mode: Returning activeSession tabs'); return activeSession.value?.editorTabs.value ?? [];
return activeSession.value?.editorTabs.value ?? []; // activeSession
} }
}); });
const activeEditorTabId = computed(() => { const activeEditorTabId = computed(() => {
if (shareFileEditorTabsBoolean.value) { if (shareFileEditorTabsBoolean.value) {
// console.log('[WorkspaceView] Shared Mode: Returning globalActiveEditorTabId'); return globalActiveEditorTabId.value;
return globalActiveEditorTabId.value; // store activeTabId
} else { } else {
// console.log('[WorkspaceView] Independent Mode: Returning activeSession activeEditorTabId'); return activeSession.value?.activeEditorTabId.value ?? null;
return activeSession.value?.activeEditorTabId.value ?? null; // activeSession
} }
}); });
@@ -64,10 +51,12 @@ const activeEditorTabId = computed(() => {
// --- UI () --- // --- UI () ---
const showAddEditForm = ref(false); const showAddEditForm = ref(false);
const connectionToEdit = ref<ConnectionInfo | null>(null); const connectionToEdit = ref<ConnectionInfo | null>(null);
const showLayoutConfigurator = ref(false); //
// --- --- // --- ---
onMounted(() => { onMounted(() => {
console.log('[工作区视图] 组件已挂载。'); console.log('[工作区视图] 组件已挂载。');
// (layoutStore )
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -101,81 +90,92 @@ onBeforeUnmount(() => {
handleFormClose(); handleFormClose();
}; };
// //
const handleOpenLayoutConfigurator = () => {
showLayoutConfigurator.value = true;
};
const handleCloseLayoutConfigurator = () => {
showLayoutConfigurator.value = false;
};
// --- ( LayoutRenderer 使) ---
// ( CommandBar, CommandHistory, QuickCommands)
const handleSendCommand = (command: string) => { const handleSendCommand = (command: string) => {
const currentSession = activeSession.value; // const currentSession = activeSession.value;
if (!currentSession) { if (!currentSession) {
console.warn('[WorkspaceView] Cannot send command, no active session.'); console.warn('[WorkspaceView] Cannot send command, no active session.');
return; return;
} }
const terminalManager = currentSession.terminalManager as (SshTerminalInstance | undefined); const terminalManager = currentSession.terminalManager as (SshTerminalInstance | undefined);
//
if (terminalManager?.isSshConnected && !terminalManager.isSshConnected.value && command.trim() === '') { if (terminalManager?.isSshConnected && !terminalManager.isSshConnected.value && command.trim() === '') {
//
console.log(`[WorkspaceView] Command bar Enter detected in disconnected session ${currentSession.sessionId}, attempting reconnect...`); console.log(`[WorkspaceView] Command bar Enter detected in disconnected session ${currentSession.sessionId}, attempting reconnect...`);
//
if (terminalManager.terminalInstance?.value) { if (terminalManager.terminalInstance?.value) {
terminalManager.terminalInstance.value.writeln(`\r\n\x1b[33m${t('workspace.terminal.reconnectingMsg')}\x1b[0m`); terminalManager.terminalInstance.value.writeln(`\r\n\x1b[33m${t('workspace.terminal.reconnectingMsg')}\x1b[0m`);
} }
sessionStore.handleConnectRequest(currentSession.connectionId); sessionStore.handleConnectRequest(currentSession.connectionId);
return; // return;
} }
//
if (terminalManager && typeof terminalManager.sendData === 'function') {
const commandToSend = command.trim(); //
console.log(`[WorkspaceView] Sending command to active session ${currentSession.sessionId}: ${commandToSend}`);
//
terminalManager.sendData(command + '\r');
// if (terminalManager && typeof terminalManager.sendData === 'function') {
const commandToSend = command.trim();
console.log(`[WorkspaceView] Sending command to active session ${currentSession.sessionId}: ${commandToSend}`);
terminalManager.sendData(command + '\r');
if (commandToSend.length > 0) { if (commandToSend.length > 0) {
commandHistoryStore.addCommand(commandToSend); commandHistoryStore.addCommand(commandToSend);
} }
} else { } else {
console.warn(`[WorkspaceView] Cannot send command for session ${currentSession.sessionId}, terminal manager or sendData method not available.`); console.warn(`[WorkspaceView] Cannot send command for session ${currentSession.sessionId}, terminal manager or sendData method not available.`);
//
} }
}; };
// --- --- // ( Terminal)
const handleTerminalInput = (sessionId: string, data: string) => { // LayoutRenderer Terminal emit('terminal-input', sessionId, data)
const session = sessionStore.sessions.get(sessionId); // session const handleTerminalInput = (payload: { sessionId: string; data: string }) => {
const manager = session?.terminalManager as (SshTerminalInstance | undefined); // terminalManager const { sessionId, data } = payload; // payload
const session = sessionStore.sessions.get(sessionId);
const manager = session?.terminalManager as (SshTerminalInstance | undefined);
if (!session || !manager) { if (!session || !manager) {
console.warn(`[WorkspaceView] handleTerminalInput: 未找到会话 ${sessionId} 或其 terminalManager`); console.warn(`[WorkspaceView] handleTerminalInput: 未找到会话 ${sessionId} 或其 terminalManager`);
return; return;
} }
// SSH
// manager.isSshConnected 访 .value
if (data === '\r' && manager.isSshConnected && !manager.isSshConnected.value) { if (data === '\r' && manager.isSshConnected && !manager.isSshConnected.value) {
console.log(`[WorkspaceView] 检测到在断开的会话 ${sessionId} 中按下回车,尝试重连...`); console.log(`[WorkspaceView] 检测到在断开的会话 ${sessionId} 中按下回车,尝试重连...`);
// ( manager terminalInstance)
if (manager.terminalInstance?.value) { if (manager.terminalInstance?.value) {
manager.terminalInstance.value.writeln(`\r\n\x1b[33m${t('workspace.terminal.reconnectingMsg')}\x1b[0m`); manager.terminalInstance.value.writeln(`\r\n\x1b[33m${t('workspace.terminal.reconnectingMsg')}\x1b[0m`);
} else { } else {
console.warn(`[WorkspaceView] 无法写入重连提示,terminalInstance 不可用。`); console.warn(`[WorkspaceView] 无法写入重连提示,terminalInstance 不可用。`);
} }
// sessionStore
sessionStore.handleConnectRequest(session.connectionId); sessionStore.handleConnectRequest(session.connectionId);
} else { } else {
//
manager.handleTerminalData(data); manager.handleTerminalData(data);
} }
}; };
// --- --- // ( Terminal)
// LayoutRenderer Terminal emit('terminal-resize', sessionId, dims)
const handleTerminalResize = (payload: { sessionId: string; dims: { cols: number; rows: number } }) => {
console.log(`[工作区视图 ${payload.sessionId}] 收到 resize 事件:`, payload.dims);
sessionStore.sessions.get(payload.sessionId)?.terminalManager.handleTerminalResize(payload.dims);
};
// ( Terminal)
// LayoutRenderer Terminal emit('terminal-ready', payload)
const handleTerminalReady = (payload: { sessionId: string; terminal: Terminal }) => { // *** sessionId terminal payload ***
console.log(`[工作区视图 ${payload.sessionId}] 收到 terminal-ready 事件。`); //
sessionStore.sessions.get(payload.sessionId)?.terminalManager.handleTerminalReady(payload.terminal); // *** terminal ***
};
// --- ( FileEditorContainer) ---
const handleCloseEditorTab = (tabId: string) => { const handleCloseEditorTab = (tabId: string) => {
const isShared = shareFileEditorTabsBoolean.value; // const isShared = shareFileEditorTabsBoolean.value;
console.log(`[WorkspaceView] handleCloseEditorTab: ${tabId}, Shared mode: ${isShared}`); console.log(`[WorkspaceView] handleCloseEditorTab: ${tabId}, Shared mode: ${isShared}`);
if (isShared) { if (isShared) {
fileEditorStore.closeTab(tabId); fileEditorStore.closeTab(tabId);
} else { } else {
const currentActiveSessionId = activeSessionId.value; // activeSessionId const currentActiveSessionId = activeSessionId.value;
if (currentActiveSessionId) { if (currentActiveSessionId) {
sessionStore.closeEditorTabInSession(currentActiveSessionId, tabId); sessionStore.closeEditorTabInSession(currentActiveSessionId, tabId);
} else { } else {
@@ -185,12 +185,12 @@ onBeforeUnmount(() => {
}; };
const handleActivateEditorTab = (tabId: string) => { const handleActivateEditorTab = (tabId: string) => {
const isShared = shareFileEditorTabsBoolean.value; // const isShared = shareFileEditorTabsBoolean.value;
console.log(`[WorkspaceView] handleActivateEditorTab: ${tabId}, Shared mode: ${isShared}`); console.log(`[WorkspaceView] handleActivateEditorTab: ${tabId}, Shared mode: ${isShared}`);
if (isShared) { if (isShared) {
fileEditorStore.setActiveTab(tabId); fileEditorStore.setActiveTab(tabId);
} else { } else {
const currentActiveSessionId = activeSessionId.value; // activeSessionId const currentActiveSessionId = activeSessionId.value;
if (currentActiveSessionId) { if (currentActiveSessionId) {
sessionStore.setActiveEditorTabInSession(currentActiveSessionId, tabId); sessionStore.setActiveEditorTabInSession(currentActiveSessionId, tabId);
} else { } else {
@@ -199,14 +199,13 @@ onBeforeUnmount(() => {
} }
}; };
//
const handleUpdateEditorContent = (payload: { tabId: string; content: string }) => { const handleUpdateEditorContent = (payload: { tabId: string; content: string }) => {
const isShared = shareFileEditorTabsBoolean.value; // const isShared = shareFileEditorTabsBoolean.value;
console.log(`[WorkspaceView] handleUpdateEditorContent for tab ${payload.tabId}, Shared mode: ${isShared}`); console.log(`[WorkspaceView] handleUpdateEditorContent for tab ${payload.tabId}, Shared mode: ${isShared}`);
if (isShared) { if (isShared) {
fileEditorStore.updateFileContent(payload.tabId, payload.content); fileEditorStore.updateFileContent(payload.tabId, payload.content);
} else { } else {
const currentActiveSessionId = activeSessionId.value; // activeSessionId const currentActiveSessionId = activeSessionId.value;
if (currentActiveSessionId) { if (currentActiveSessionId) {
sessionStore.updateFileContentInSession(currentActiveSessionId, payload.tabId, payload.content); sessionStore.updateFileContentInSession(currentActiveSessionId, payload.tabId, payload.content);
} else { } else {
@@ -215,14 +214,13 @@ onBeforeUnmount(() => {
} }
}; };
//
const handleSaveEditorTab = (tabId: string) => { const handleSaveEditorTab = (tabId: string) => {
const isShared = shareFileEditorTabsBoolean.value; // const isShared = shareFileEditorTabsBoolean.value;
console.log(`[WorkspaceView] handleSaveEditorTab: ${tabId}, Shared mode: ${isShared}`); console.log(`[WorkspaceView] handleSaveEditorTab: ${tabId}, Shared mode: ${isShared}`);
if (isShared) { if (isShared) {
fileEditorStore.saveFile(tabId); fileEditorStore.saveFile(tabId);
} else { } else {
const currentActiveSessionId = activeSessionId.value; // activeSessionId const currentActiveSessionId = activeSessionId.value;
if (currentActiveSessionId) { if (currentActiveSessionId) {
sessionStore.saveFileInSession(currentActiveSessionId, tabId); sessionStore.saveFileInSession(currentActiveSessionId, tabId);
} else { } else {
@@ -230,152 +228,56 @@ onBeforeUnmount(() => {
} }
} }
}; };
// --- ( WorkspaceConnectionList) ---
const handleConnectRequest = (id: number) => {
console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`);
sessionStore.handleConnectRequest(id);
};
const handleOpenNewSession = (id: number) => {
console.log(`[WorkspaceView] Received 'open-new-session' event for ID: ${id}`);
sessionStore.handleOpenNewSession(id);
};
</script> </script>
<template> <template>
<div class="workspace-view"> <div class="workspace-view"> <!-- Root element -->
<TerminalTabBar <TerminalTabBar
:sessions="sessionTabsWithStatus" :sessions="sessionTabsWithStatus"
:active-session-id="activeSessionId" :active-session-id="activeSessionId"
@activate-session="sessionStore.activateSession" @activate-session="sessionStore.activateSession"
@close-session="sessionStore.closeSession" @close-session="sessionStore.closeSession"
@open-layout-configurator="handleOpenLayoutConfigurator"
/> />
<div class="main-content-area"> <div class="main-content-area">
<!-- 最外层左右分割 (连接列表 | 中间区域 | 编辑器 | 状态监视器) --> <LayoutRenderer
<splitpanes class="default-theme" :horizontal="false" style="height: 100%"> v-if="layoutTree"
:layout-node="layoutTree"
<!-- 1. 左侧边栏 Pane (连接列表) --> :active-session-id="activeSessionId"
<pane v-if="paneVisibility.connections" size="15" min-size="10" class="sidebar-pane"> <!-- 移除 pane-with-title class --> class="layout-renderer-wrapper"
<WorkspaceConnectionListComponent :editor-tabs="editorTabs"
class="pane-content" :active-editor-tab-id="activeEditorTabId"
@connect-request="(id) => { console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`); sessionStore.handleConnectRequest(id); }" @send-command="handleSendCommand"
@open-new-session="(id) => { console.log(`[WorkspaceView] Received 'open-new-session' event for ID: ${id}`); sessionStore.handleOpenNewSession(id); }" @terminal-input="handleTerminalInput"
@request-add-connection="() => { console.log('[WorkspaceView] Received \'request-add-connection\' event'); handleRequestAddConnection(); }" @terminal-resize="handleTerminalResize"
@request-edit-connection="(conn) => { console.log(`[WorkspaceView] Received 'request-edit-connection' event for connection:`, conn); handleRequestEditConnection(conn); }" @terminal-ready="handleTerminalReady"
/> @close-editor-tab="handleCloseEditorTab"
</pane> @activate-editor-tab="handleActivateEditorTab"
@update-editor-content="handleUpdateEditorContent"
<!-- 新增命令历史 Pane --> @save-editor-tab="handleSaveEditorTab"
<pane v-if="paneVisibility.commandHistory" size="15" min-size="10" class="sidebar-pane command-history-pane"> @connect-request="handleConnectRequest"
<CommandHistoryView class="pane-content" @execute-command="handleSendCommand" /> @open-new-session="handleOpenNewSession"
</pane> @request-add-connection="handleRequestAddConnection"
@request-edit-connection="handleRequestEditConnection"
<!-- 新增快捷指令 Pane --> ></LayoutRenderer> <!-- 修正使用单独的结束标签 -->
<pane v-if="paneVisibility.quickCommands" size="15" min-size="10" class="sidebar-pane quick-commands-pane"> <div v-else class="pane-placeholder"> <!-- 确保 v-else 紧随 v-if -->
<QuickCommandsView class="pane-content" @execute-command="handleSendCommand" /> <!-- 监听事件 --> {{ t('layout.loading', '加载布局中...') }}
</pane> </div>
<!-- 2. 中间区域 Pane (终端/命令栏/文件管理器) - 这个 Pane 本身通常保持可见内部 Pane 才切换 -->
<pane size="30" min-size="20" class="middle-pane"> <!-- 再次调整中间区域大小 -->
<!-- 上下分割 (终端 | 命令栏 | 文件管理器) -->
<splitpanes :horizontal="true" style="height: 100%" :dbl-click-splitter="false">
<!-- 上方 Pane (终端) -->
<pane v-if="paneVisibility.terminal" size="55" min-size="20" class="terminal-pane"> <!-- 移除 pane-with-title class -->
<div class="pane-content terminal-content-wrapper"> <!-- 添加包裹 div -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId"
v-show="tabInfo.sessionId === activeSessionId"
class="terminal-session-wrapper"
>
<TerminalComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:is-active="tabInfo.sessionId === activeSessionId"
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
@data="(data) => handleTerminalInput(tabInfo.sessionId, data)"
@resize="(dims) => { console.log(`[工作区视图 ${tabInfo.sessionId}] 收到 resize 事件:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
/>
</div>
<div v-if="!activeSessionId" class="terminal-placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
</div>
</pane> <!-- End Terminal Pane -->
<!-- 中间 Pane (命令栏) - 移除标题栏但保留 v-if -->
<pane v-if="paneVisibility.commandBar" size="5" min-size="5" class="command-bar-pane">
<CommandInputBar
v-if="activeSessionId"
@send-command="handleSendCommand"
/>
</pane> <!-- End Command Bar Pane -->
<!-- 下方 Pane (文件管理器区域 - 包含新的水平分割) -->
<pane v-if="paneVisibility.fileManager" size="40" min-size="15" class="file-manager-area-pane"> <!-- 移除 pane-with-title class -->
<!-- 新增内部水平分割允许未来添加列 -->
<splitpanes :horizontal="false" style="height: 100%" :dbl-click-splitter="false" class="pane-content"> <!-- 添加 class -->
<!-- 初始的文件管理器 Pane -->
<pane class="file-manager-pane"> <!-- 这个内部 pane 不需要 title bar -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId + '-fm-wrapper'"
v-show="tabInfo.sessionId === activeSessionId"
class="file-manager-wrapper"
>
<FileManagerComponent
v-if="sessionStore.sessions.get(tabInfo.sessionId)"
:key="tabInfo.sessionId + '-fm'"
:session-id="tabInfo.sessionId"
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
:ws-deps="{
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
}"
/>
</div>
<div v-if="!activeSessionId" class="pane-placeholder">{{ t('fileManager.noActiveSession') }}</div>
</pane> <!-- End Inner File Manager Pane -->
<!-- 这里可以将来添加其他 Pane -->
</splitpanes> <!-- End Inner Horizontal Splitpanes -->
</pane> <!-- End File Manager Area Pane -->
</splitpanes> <!-- End Middle Area Vertical Splitpanes -->
</pane> <!-- End Middle Pane -->
<!-- 3. 右侧区域 1 Pane (文件编辑器) -->
<pane v-if="paneVisibility.editor" size="20" min-size="15" class="file-editor-pane"> <!-- 移除 pane-with-title class -->
<FileEditorContainer
class="pane-content"
:tabs="editorTabs"
:active-tab-id="activeEditorTabId"
:session-id="activeSessionId"
@close-tab="handleCloseEditorTab"
@activate-tab="handleActivateEditorTab"
@update:content="handleUpdateEditorContent"
@request-save="handleSaveEditorTab"
/>
</pane>
<!-- 4. 右侧区域 2 Pane (状态监视器) -->
<pane v-if="paneVisibility.statusMonitor" size="15" min-size="10" class="sidebar-pane status-monitor-pane"> <!-- 移除 pane-with-title class -->
<div class="pane-content status-monitor-content-wrapper"> <!-- 添加包裹 div -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId + '-sm-wrapper'"
v-show="tabInfo.sessionId === activeSessionId"
class="status-monitor-wrapper"
>
<StatusMonitorComponent
v-if="sessionStore.sessions.get(tabInfo.sessionId)"
:key="tabInfo.sessionId + '-sm'"
:session-id="tabInfo.sessionId"
:server-status="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.serverStatus.value"
:status-error="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.statusError.value"
/>
</div>
<div v-if="!activeSessionId" class="pane-placeholder">{{ t('statusMonitor.noActiveSession') }}</div>
</div>
</pane>
</splitpanes>
</div> </div>
<!-- 添加/编辑连接表单模态框 (保持不变) --> <!-- Modals should be outside the main content flow -->
<AddConnectionFormComponent <AddConnectionFormComponent
v-if="showAddEditForm" v-if="showAddEditForm"
:connection-to-edit="connectionToEdit" :connection-to-edit="connectionToEdit"
@@ -383,19 +285,22 @@ onBeforeUnmount(() => {
@connection-added="handleConnectionAdded" @connection-added="handleConnectionAdded"
@connection-updated="handleConnectionUpdated" @connection-updated="handleConnectionUpdated"
/> />
</div>
<LayoutConfigurator
:is-visible="showLayoutConfigurator"
@close="handleCloseLayoutConfigurator"
/>
</div> <!-- End of root element -->
</template> </template>
<style scoped> <style scoped>
.workspace-view { .workspace-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 60px - 30px - 2rem); /* 恢复原始高度计算 */ height: calc(100vh - 60px - 30px - 2rem); /* 保持原始高度计算 */
overflow: hidden; overflow: hidden;
} }
/* 移除 fixed-command-bar 样式 */
.main-content-area { .main-content-area {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -403,157 +308,14 @@ onBeforeUnmount(() => {
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
} }
/* 为 Pane 添加一些基本样式 */ .layout-renderer-wrapper {
/* .pane-with-title 已移除 */
.pane-content { /* 让内容区域填充剩余空间 */
flex-grow: 1; flex-grow: 1;
overflow: auto; /* 或者 hidden,根据需要 */ width: 100%;
display: flex; /* 内部可能还需要 flex 布局 */ height: 100%;
flex-direction: column; /* 默认列方向 */ overflow: hidden;
} }
.sidebar-pane, /* 用于左右侧边栏 */ /* 面板占位符样式 (用于加载或错误状态) */
.middle-pane, /* 中间包含终端、命令栏、文件管理器的 Pane */
.terminal-pane,
.command-bar-pane,
.file-editor-pane, /* 编辑器窗格样式 */
.file-manager-area-pane, /* 文件管理器区域 Pane */
.file-manager-pane, /* 内部文件管理器 Pane */
.status-monitor-pane, /* 状态监视器样式 */
.command-history-pane, /* 命令历史窗格样式 */
.quick-commands-pane { /* 快捷指令窗格样式 */
display: flex; /* 确保 flex 布局 */
flex-direction: column; /* 确保列方向 */
overflow: hidden; /* 默认隐藏溢出 */
background-color: #f8f9fa; /* 默认背景色 */
}
.middle-pane {
padding: 0; /* 移除 middle-pane 的内边距 */
}
/* 命令栏 Pane 特定样式 - 恢复原样 */
.command-bar-pane {
background-color: #e9ecef; /* 背景色 */
/* justify-content: center; /* 垂直居中输入框 - 移除此行 */
overflow: hidden; /* 内容不应超出 */
display: flex; /* 确保 flex 布局 */
align-items: center; /* 垂直居中 */
}
/* 调整内部 CommandInputBar 样式 - 恢复原样 */
.command-bar-pane > .command-input-bar {
border: none;
background-color: transparent;
min-height: auto;
padding: 2px 0; /* 移除水平内边距 */
flex-grow: 1; /* 让输入框填充 */
width: 80%; /* 显式设置宽度为100% */
}
.terminal-pane {
background-color: #f8f9fa; /* 外层 pane 背景 */
/* position: relative; 由内部 wrapper 处理 */
}
.terminal-content-wrapper {
background-color: #1e1e1e; /* 终端实际背景 */
position: relative; /* 用于占位符 */
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* <-- 重新添加,隐藏外部滚动条 */
}
.file-editor-pane {
background-color: #f8f9fa; /* 外层 pane 背景 */
}
/* FileEditorContainer 自身需要 flex-grow: 1 */
.file-editor-pane > .pane-content {
background-color: #2d2d2d; /* 编辑器容器背景 */
}
.file-manager-area-pane {
padding: 0;
background-color: #f8f9fa; /* 外层 pane 背景 */
}
/* 内部的 splitpanes 需要 flex-grow: 1 */
.file-manager-area-pane > .pane-content {
background-color: #f0f0f0; /* 内部区域背景 */
}
.file-manager-pane { /* 内部文件管理器 Pane */
background-color: #ffffff; /* 文件管理器使用浅色背景 */
display: flex; /* 确保内部 flex 布局 */
flex-direction: column;
overflow: hidden;
}
.status-monitor-pane {
background-color: #f8f9fa; /* 外层 pane 背景 */
/* text-align: center; 由内部 wrapper 处理 */
/* padding: 1rem; 由内部 wrapper 处理 */
}
.command-history-pane {
background-color: #f8f9fa; /* 与其他侧边栏一致 */
}
.quick-commands-pane {
background-color: #f8f9fa; /* 与其他侧边栏一致 */
}
.status-monitor-content-wrapper {
text-align: center;
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: auto; /* 允许内容滚动 */
}
/* 终端会话包装器 */
.terminal-session-wrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* <-- 重新添加,隐藏外部滚动条 */
}
/* 文件管理器包装器 (内部组件应填充) */
.file-manager-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 状态监视器包装器 (内部组件应填充) */
.status-monitor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* 内部组件自己处理滚动 */
}
/* 终端占位符 */
.terminal-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #6c757d;
padding: 2rem;
background-color: #f8f9fa; /* 与 pane 背景一致 */
}
.terminal-placeholder h2 {
margin-bottom: 0.5rem;
font-weight: 300;
color: #495057;
}
.terminal-placeholder p {
font-size: 1em;
}
/* 面板占位符样式 */
.pane-placeholder { .pane-placeholder {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@@ -566,49 +328,6 @@ onBeforeUnmount(() => {
padding: 1rem; padding: 1rem;
} }
/* Splitpanes 默认主题样式调整 */ /* 移除旧的、不再需要的特定面板样式,因为渲染由 LayoutRenderer 处理 */
.splitpanes.default-theme .splitpanes__splitter {
background-color: #ccc;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
border-left: 1px solid #eee; /* 可选:添加细微边框 */
border-right: 1px solid #eee;
}
.splitpanes--vertical > .splitpanes__splitter {
width: 7px; /* 垂直分割线宽度 */
cursor: col-resize;
}
.splitpanes--horizontal > .splitpanes__splitter {
height: 7px; /* 水平分割线高度 */
cursor: row-resize;
}
.splitpanes.default-theme .splitpanes__splitter:before {
content: '';
position: absolute;
left: 0;
top: 0;
transition: opacity 0.4s;
background-color: rgba(0, 0, 0, 0.15);
opacity: 0;
z-index: 1;
}
.splitpanes.default-theme .splitpanes__splitter:hover:before {
opacity: 1;
}
.splitpanes.default-theme.splitpanes--vertical > .splitpanes__splitter:before {
left: 2px; /* 调整指示器位置 */
right: 2px;
height: 100%;
}
.splitpanes.default-theme.splitpanes--horizontal > .splitpanes__splitter:before {
top: 2px; /* 调整指示器位置 */
bottom: 2px;
width: 100%;
}
/* 尝试提高中间区域水平分割线的 z-index */
.middle-pane .splitpanes--horizontal > .splitpanes__splitter {
z-index: 10; /* 确保分割线在内容之上 */
}
</style> </style>