Files
fauto-design/draggable-panels/src/fauto/materials/TreeViewer/index.vue
2026-01-20 21:53:09 +08:00

540 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { ref, watch, onMounted, onUnmounted } from 'vue'
import { useVueFileStore } from '../../stores/vueFileStore'
import { useDesignStore } from '../../stores/designStore'
import config from './index.json'
const TEMPLATE_SERVICE_URL = 'http://localhost:3001'
const vueFileStore = useVueFileStore()
const designStore = useDesignStore()
// 元素树节点类型
interface ElementNode {
type: 'row' | 'col'
path: string
label: string
componentName?: string | null
children?: ElementNode[]
}
// 元素树数据
const elementTree = ref<ElementNode[]>([])
const loading = ref(false)
// 拖拽状态
const draggingNode = ref<ElementNode | null>(null)
const dropTarget = ref<{ node: ElementNode, position: 'before' | 'after' | 'inside' } | null>(null)
// 右键菜单状态
const contextMenu = ref<{ x: number, y: number, node: ElementNode } | null>(null)
// 选中的节点
const selectedPath = ref<string | null>(null)
// 获取页面元素结构
const fetchElementTree = async () => {
if (!vueFileStore.selectedFilePath) {
elementTree.value = []
return
}
loading.value = true
try {
const response = await fetch(
`${TEMPLATE_SERVICE_URL}/api/element-tree?pagePath=${encodeURIComponent(vueFileStore.selectedFilePath)}`
)
const result = await response.json()
if (result.success) {
elementTree.value = result.tree || []
} else {
elementTree.value = []
}
} catch (error) {
elementTree.value = []
} finally {
loading.value = false
}
}
// 获取节点显示文本
const getNodeText = (node: ElementNode): string => {
if (node.type === 'row') return node.path
if (node.componentName) return `${node.path}-${node.componentName}`
return node.path
}
// 判断是否为叶子节点
const isLeafNode = (node: ElementNode): boolean => {
return node.type === 'col' && !!node.componentName && (!node.children || node.children.length === 0)
}
// 计算允许的放置位置
const getAllowedPosition = (source: ElementNode, target: ElementNode): 'before' | 'after' | 'inside' | null => {
if (source.path === target.path) return null
if (target.path.startsWith(source.path)) return null
if (source.type === 'col' && target.type === 'col') return 'after'
if (source.type === 'row' && target.type === 'row') return 'after'
if (source.type === 'col' && target.type === 'row') return 'inside'
if (source.type === 'row' && target.type === 'col' && !isLeafNode(target)) return 'inside'
return null
}
// 点击节点 - 选中组件
const handleNodeClick = (node: ElementNode) => {
selectedPath.value = node.path
// 如果是叶子节点,选中组件显示元数据
if (isLeafNode(node) && node.componentName && vueFileStore.selectedFilePath) {
designStore.selectComponent(node.path, node.componentName, vueFileStore.selectedFilePath)
}
// 高亮设计中心对应元素
highlightDesignCenterElement(node.path)
}
// 高亮设计中心的对应元素
const highlightDesignCenterElement = (path: string) => {
// 移除其他选中
document.querySelectorAll('.fauto-selected').forEach(el => {
el.classList.remove('fauto-selected')
})
// 找到对应元素并选中
const element = document.querySelector(`[data-path="${path}"]`)
if (element) {
element.classList.add('fauto-selected')
// 滚动到可见区域
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 监听设计中心的选中事件,同步选中结构树
const handleDesignComponentSelected = (e: CustomEvent) => {
const { path } = e.detail
if (path) {
selectedPath.value = path
}
}
// 右键菜单
const handleContextMenu = (e: MouseEvent, node: ElementNode) => {
e.preventDefault()
e.stopPropagation()
contextMenu.value = { x: e.clientX, y: e.clientY, node }
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value = null
}
// 删除节点
const deleteNode = async (node: ElementNode) => {
if (!vueFileStore.selectedFilePath) return
if (!confirm(`确定要删除 ${getNodeText(node)} 吗?`)) return
try {
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/delete-element`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
elementPath: node.path
})
})
const result = await response.json()
if (result.success) {
console.log('[结构] 删除成功:', node.path)
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
// 如果删除的是选中的组件,清除选中
if (designStore.selectedComponent?.path === node.path) {
designStore.clearSelection()
}
} else {
alert('删除失败: ' + result.error)
}
} catch (error) {
console.error('[结构] 删除失败:', error)
}
closeContextMenu()
}
// 键盘事件处理
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedPath.value) {
const node = findNodeByPath(elementTree.value, selectedPath.value)
if (node) {
deleteNode(node)
}
}
if (e.key === 'Escape') {
closeContextMenu()
}
}
// 查找节点
const findNodeByPath = (nodes: ElementNode[], path: string): ElementNode | null => {
for (const node of nodes) {
if (node.path === path) return node
if (node.children) {
const found = findNodeByPath(node.children, path)
if (found) return found
}
}
return null
}
// 拖拽事件
const handleDragStart = (e: DragEvent, node: ElementNode) => {
draggingNode.value = node
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.path)
}
}
const handleDragOver = (e: DragEvent, node: ElementNode) => {
if (!draggingNode.value) return
const position = getAllowedPosition(draggingNode.value, node)
if (position) {
e.preventDefault()
dropTarget.value = { node, position }
} else {
dropTarget.value = null
}
}
const handleDragLeave = () => {
dropTarget.value = null
}
const handleDrop = async (e: DragEvent, node: ElementNode) => {
e.preventDefault()
if (!draggingNode.value || !vueFileStore.selectedFilePath) {
resetDragState()
return
}
const position = getAllowedPosition(draggingNode.value, node)
if (!position) {
resetDragState()
return
}
let direction: string
if (position === 'inside') {
direction = 'inside'
} else if (position === 'after') {
direction = node.type === 'row' ? 'bottom' : 'right'
} else {
direction = node.type === 'row' ? 'top' : 'left'
}
try {
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/move-element`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
source: {
type: 'canvas-element',
path: draggingNode.value.path,
elementType: draggingNode.value.type === 'row' ? 'er' : 'ec'
},
targetPath: node.path,
targetType: node.type === 'row' ? 'er' : 'ec',
direction
})
})
const result = await response.json()
if (result.success) {
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
}
} catch (error) {
console.error('[结构] 移动失败:', error)
}
resetDragState()
}
const handleDragEnd = () => {
resetDragState()
}
const resetDragState = () => {
draggingNode.value = null
dropTarget.value = null
}
// 递归渲染树
const flattenTree = (nodes: ElementNode[], depth = 0): Array<{ node: ElementNode, depth: number }> => {
const result: Array<{ node: ElementNode, depth: number }> = []
for (const node of nodes) {
result.push({ node, depth })
if (node.children && node.children.length > 0) {
result.push(...flattenTree(node.children, depth + 1))
}
}
return result
}
// 获取节点样式类
const getNodeClass = (node: ElementNode) => {
const classes: string[] = []
if (node.type === 'row') classes.push('is-row')
if (node.type === 'col') classes.push('is-col')
if (draggingNode.value?.path === node.path) classes.push('is-dragging')
if (selectedPath.value === node.path) classes.push('is-selected')
if (dropTarget.value?.node.path === node.path) {
classes.push('is-drop-target')
classes.push(`drop-${dropTarget.value.position}`)
}
return classes
}
// 监听
watch(() => vueFileStore.selectedFilePath, () => {
fetchElementTree()
}, { immediate: true })
onMounted(() => {
window.addEventListener('vue-template-updated', () => {
setTimeout(fetchElementTree, 300)
})
// 监听设计中心的选中事件
window.addEventListener('design-component-selected', handleDesignComponentSelected as EventListener)
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', closeContextMenu)
// 阻止组件内的浏览器默认右键菜单
document.addEventListener('contextmenu', handleGlobalContextMenu)
})
// 全局右键菜单处理,阻止浏览器默认菜单
const handleGlobalContextMenu = (e: MouseEvent) => {
// 检查是否在树节点上点击
const target = e.target as HTMLElement
if (target.closest('.tree-node')) {
e.preventDefault()
}
}
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', closeContextMenu)
document.removeEventListener('contextmenu', handleGlobalContextMenu)
window.removeEventListener('design-component-selected', handleDesignComponentSelected as EventListener)
})
</script>
<template>
<div class="tree-viewer">
<div class="viewer-header">
<span class="title">{{ config.name }}</span>
</div>
<div class="viewer-body">
<div v-if="loading" class="loading-tip">加载中...</div>
<div v-else-if="elementTree.length === 0" class="empty-tip">
{{ vueFileStore.selectedFilePath ? '暂无布局元素' : '请先选择页面' }}
</div>
<div v-else class="tree-container">
<div
v-for="item in flattenTree(elementTree)"
:key="item.node.path"
class="tree-node"
:class="getNodeClass(item.node)"
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
draggable="true"
@click="handleNodeClick(item.node)"
@contextmenu="handleContextMenu($event, item.node)"
@dragstart="handleDragStart($event, item.node)"
@dragover="handleDragOver($event, item.node)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, item.node)"
@dragend="handleDragEnd"
>
<span class="node-text">{{ getNodeText(item.node) }}</span>
<span v-if="dropTarget?.node.path === item.node.path" class="drop-hint">
{{ dropTarget.position === 'inside' ? '放入' : '后面' }}
</span>
<span class="delete-icon" @click.stop="deleteNode(item.node)" title="删除">🗑</span>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="menu-item" @click="deleteNode(contextMenu.node)">
<span class="menu-icon">🗑</span>
<span>删除</span>
</div>
</div>
</div>
</template>
<style scoped>
.tree-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
position: relative;
}
.viewer-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
}
.title {
color: #cccccc;
font-size: 13px;
font-weight: 500;
}
.viewer-body {
flex: 1;
padding: 8px;
overflow: auto;
}
.tree-container {
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
}
.tree-node {
padding: 4px 8px;
cursor: grab;
border-radius: 3px;
color: #888;
user-select: none;
margin-bottom: 1px;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 8px;
}
.tree-node:hover {
background: #2a2d2e;
}
.tree-node:hover .delete-icon {
opacity: 1;
}
.tree-node:active {
cursor: grabbing;
}
.tree-node.is-row {
color: #4fc3f7;
}
.tree-node.is-col {
color: #c586c0;
}
.tree-node.is-dragging {
opacity: 0.5;
}
.tree-node.is-selected {
background: #094771;
}
.tree-node.is-drop-target {
background: #094771;
}
.tree-node.drop-inside {
border: 1px dashed #4fc3f7;
}
.tree-node.drop-after {
border-bottom: 2px solid #4fc3f7;
}
.node-text {
flex: 1;
}
.drop-hint {
font-size: 10px;
color: #4fc3f7;
background: rgba(79, 195, 247, 0.2);
padding: 1px 4px;
border-radius: 2px;
}
.delete-icon {
opacity: 0;
cursor: pointer;
font-size: 12px;
transition: opacity 0.15s;
}
.delete-icon:hover {
transform: scale(1.1);
}
.loading-tip,
.empty-tip {
color: #666666;
text-align: center;
padding: 20px;
font-size: 12px;
}
/* 右键菜单 */
.context-menu {
position: fixed;
background: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
min-width: 120px;
}
.menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: #ccc;
font-size: 12px;
}
.menu-item:hover {
background: #094771;
}
.menu-icon {
font-size: 14px;
}
</style>