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
@@ -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<{
(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;