Files
nexus-terminal/packages/frontend/src/components/LayoutNodeEditor.vue
T
Baobhan Sith 54ea8f34e3 update
2025-04-17 16:21:32 +08:00

354 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>