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