update
This commit is contained in:
@@ -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', '关闭')">×</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<{
|
||||
(e: 'data', data: string): 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); // 终端容器的引用
|
||||
@@ -185,8 +185,10 @@ onMounted(() => {
|
||||
}
|
||||
}, { immediate: true }); // 立即执行一次 watch
|
||||
|
||||
// 触发 ready 事件
|
||||
emit('ready', terminal);
|
||||
// 触发 ready 事件,传递 sessionId 和 terminal 实例
|
||||
if (terminal) { // 确保 terminal 实例已创建
|
||||
emit('ready', { sessionId: props.sessionId, terminal: terminal });
|
||||
}
|
||||
|
||||
// 聚焦终端
|
||||
terminal.focus();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, PropType } from 'vue'; // 导入 ref 和 computed
|
||||
import { useI18n } from 'vue-i18n'; // 导入 i18n
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
// import { storeToRefs } from 'pinia'; // 移除 storeToRefs 导入,因为 paneVisibility 不再使用
|
||||
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue'; // 导入连接列表组件
|
||||
import { useSessionStore } from '../stores/session.store'; // 导入 session store
|
||||
import { useLayoutStore, type PaneName } from '../stores/layout.store'; // 导入布局 store 和类型
|
||||
@@ -11,7 +11,7 @@ import type { SessionTabInfoWithStatus } from '../stores/session.store'; // 导
|
||||
// --- Setup ---
|
||||
const { t } = useI18n(); // 初始化 i18n
|
||||
const layoutStore = useLayoutStore(); // 初始化布局 store
|
||||
const { paneVisibility } = storeToRefs(layoutStore); // 修正:使用 storeToRefs 获取响应式状态
|
||||
// const { paneVisibility } = storeToRefs(layoutStore); // 移除:paneVisibility 不再存在
|
||||
|
||||
// 定义 Props
|
||||
const props = defineProps({
|
||||
@@ -20,13 +20,14 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
activeSessionId: {
|
||||
type: String as PropType<string | null>,
|
||||
required: true,
|
||||
type: String as PropType<string | null>, // 类型已包含 null
|
||||
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) => {
|
||||
if (sessionId !== props.activeSessionId) {
|
||||
@@ -63,25 +64,32 @@ const toggleLayoutMenu = () => {
|
||||
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> = {
|
||||
connections: t('layout.pane.connections'),
|
||||
terminal: t('layout.pane.terminal'),
|
||||
commandBar: t('layout.pane.commandBar'), // 恢复
|
||||
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', '快捷指令'), // 添加快捷指令标签
|
||||
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) => {
|
||||
layoutStore.togglePaneVisibility(paneName);
|
||||
// 可以选择点击后关闭菜单,或者保持打开
|
||||
console.warn(`[TabBar] 旧的 handleTogglePane 被调用,但 togglePaneVisibility 已移除。面板: ${paneName}`);
|
||||
// layoutStore.togglePaneVisibility(paneName); // 此方法已不存在
|
||||
// showLayoutMenu.value = false;
|
||||
};
|
||||
|
||||
@@ -111,22 +119,30 @@ const handleTogglePane = (paneName: PaneName) => {
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 布局菜单按钮容器(推到最右侧) -->
|
||||
<div class="layout-menu-container">
|
||||
<button class="layout-menu-button" @click="toggleLayoutMenu" title="调整布局">
|
||||
<i class="fas fa-bars"></i> <!-- 使用 Font Awesome bars 图标 -->
|
||||
<!-- 按钮容器(推到最右侧) -->
|
||||
<div class="action-buttons-container">
|
||||
<!-- 新增:布局配置器按钮 -->
|
||||
<button class="layout-config-button" @click="openLayoutConfigurator" title="配置工作区布局">
|
||||
<i class="fas fa-th-large"></i> <!-- 网格布局图标 -->
|
||||
</button>
|
||||
<!-- 布局菜单下拉列表 (保持不变) -->
|
||||
<div v-if="showLayoutMenu" class="layout-menu-dropdown">
|
||||
<ul>
|
||||
<li v-for="pane in availablePanes" :key="pane" @click="handleTogglePane(pane)">
|
||||
<span class="checkmark">{{ paneVisibility[pane] ? '✓' : '' }}</span>
|
||||
{{ paneLabels[pane] || pane }}
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 保留旧的布局菜单按钮 -->
|
||||
<div class="layout-menu-container">
|
||||
<button class="layout-menu-button" @click="toggleLayoutMenu" title="切换面板可见性 (旧)">
|
||||
<i class="fas fa-bars"></i> <!-- 汉堡菜单图标 -->
|
||||
</button>
|
||||
<!-- 旧布局菜单下拉列表 (显示所有面板,但勾选状态和点击功能失效) -->
|
||||
<div v-if="showLayoutMenu" class="layout-menu-dropdown">
|
||||
<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 v-if="showConnectionListPopup" class="connection-list-popup" @click.self="togglePopup">
|
||||
<div class="popup-content">
|
||||
@@ -280,7 +296,37 @@ const handleTogglePane = (paneName: PaneName) => {
|
||||
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 {
|
||||
@@ -346,17 +392,18 @@ const handleTogglePane = (paneName: PaneName) => {
|
||||
padding: 0; /* 保持移除内边距 */
|
||||
}
|
||||
|
||||
/* 新增:布局菜单样式 */
|
||||
/* 调整:旧布局菜单容器样式 */
|
||||
.layout-menu-container {
|
||||
position: relative; /* 用于定位下拉菜单 */
|
||||
display: flex; /* 确保按钮垂直居中 */
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-left: auto; /* 保持:将布局按钮推到最右侧 */
|
||||
border-left: 1px solid #bdbdbd; /* 确保布局按钮左侧有分隔线 */
|
||||
/* margin-left: auto; */ /* 移除:由父容器 .action-buttons-container 控制 */
|
||||
border-left: 1px solid #bdbdbd; /* 保持左侧分隔线 */
|
||||
flex-shrink: 0; /* 保持:防止被压缩 */
|
||||
}
|
||||
|
||||
/* 调整:旧布局菜单按钮样式 */
|
||||
.layout-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user