添加树结构组件
This commit is contained in:
264
Web/Vue/CubeLib/src/components/CubeTreeItem.vue
Normal file
264
Web/Vue/CubeLib/src/components/CubeTreeItem.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="cube-tree-item" :style="{ marginLeft: `${depth * config.indentSize}px` }">
|
||||
<div
|
||||
class="cube-tree-item-header"
|
||||
@click="handleClick"
|
||||
:class="{
|
||||
'has-children': hasChildren,
|
||||
'selected': selected,
|
||||
'expanded': item.expanded,
|
||||
'in-path': inPath
|
||||
}"
|
||||
>
|
||||
<span v-if="config.showExpandIcon && hasChildren" class="expand-icon">
|
||||
{{ item.expanded ? '▼' : '▶' }}
|
||||
</span>
|
||||
<span v-else class="expand-icon placeholder-icon"></span>
|
||||
|
||||
<slot name="icon" :item="item">
|
||||
<span class="item-icon">{{ computedIcon }}</span>
|
||||
</slot>
|
||||
|
||||
<slot name="prefix" :item="item">
|
||||
<span v-if="config.showLevel && item.level" class="level-prefix">
|
||||
{{ config.levelPrefix }}{{ item.level }}
|
||||
</span>
|
||||
</slot>
|
||||
|
||||
<slot name="title" :item="item">
|
||||
<span class="item-title">{{ item.title }}</span>
|
||||
</slot>
|
||||
|
||||
<slot name="suffix" :item="item"></slot>
|
||||
</div>
|
||||
|
||||
<transition name="expand">
|
||||
<div v-if="item.expanded && hasChildren" class="cube-tree-children">
|
||||
<CubeTreeItem
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
:item="child"
|
||||
:depth="depth + 1"
|
||||
:selected="selectedId === child.id"
|
||||
:selected-id="selectedId"
|
||||
:config="config"
|
||||
:icon-map="iconMap"
|
||||
:in-path="isInPath(child.id)"
|
||||
@node-click="emit('node-click', $event)"
|
||||
@toggle-expand="emit('toggle-expand', $event)"
|
||||
>
|
||||
<template #icon="{ item }">
|
||||
<slot name="icon" :item="item"></slot>
|
||||
</template>
|
||||
<template #title="{ item }">
|
||||
<slot name="title" :item="item"></slot>
|
||||
</template>
|
||||
<template #prefix="{ item }">
|
||||
<slot name="prefix" :item="item"></slot>
|
||||
</template>
|
||||
<template #suffix="{ item }">
|
||||
<slot name="suffix" :item="item"></slot>
|
||||
</template>
|
||||
</CubeTreeItem>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: Object,
|
||||
depth: { type: Number, default: 0 },
|
||||
selected: { type: Boolean, default: false },
|
||||
selectedId: { type: String, default: '' },
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
indentSize: 16,
|
||||
showExpandIcon: true,
|
||||
iconType: 'custom',
|
||||
showLevel: false,
|
||||
levelPrefix: 'H',
|
||||
expandable: true
|
||||
})
|
||||
},
|
||||
iconMap: Object,
|
||||
inPath: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['node-click', 'toggle-expand'])
|
||||
|
||||
const hasChildren = computed(() =>
|
||||
props.item.children && props.item.children.length > 0
|
||||
)
|
||||
|
||||
const computedIcon = computed(() => {
|
||||
const iconType = props.config.iconType
|
||||
const userIconMap = props.iconMap
|
||||
|
||||
const defaultFileIconMap = {
|
||||
'md': '📖',
|
||||
'txt': '📝',
|
||||
'pdf': '📚',
|
||||
'png': '🖼️',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'gif': '🎞️',
|
||||
'svg': '📐',
|
||||
'json': '🔧',
|
||||
'yaml': '🔧',
|
||||
'yml': '🔧',
|
||||
'default': '📚',
|
||||
'folder': '📁'
|
||||
}
|
||||
|
||||
const defaultHeadingIconMap = {
|
||||
1: '📌',
|
||||
2: '📎',
|
||||
3: '📏',
|
||||
4: '📐',
|
||||
5: '🔹',
|
||||
6: '🔸',
|
||||
'default': '📄'
|
||||
}
|
||||
|
||||
if (iconType === 'file') {
|
||||
const effectiveIconMap = userIconMap || defaultFileIconMap
|
||||
|
||||
if (hasChildren.value) {
|
||||
return effectiveIconMap.folder || '📁'
|
||||
}
|
||||
|
||||
if (props.item.id) {
|
||||
const ext = props.item.id.split('.').pop()?.toLowerCase()
|
||||
return effectiveIconMap[ext] || effectiveIconMap.default || '📄'
|
||||
}
|
||||
|
||||
return effectiveIconMap.default || '📄'
|
||||
} else if (iconType === 'heading') {
|
||||
const effectiveIconMap = userIconMap || defaultHeadingIconMap
|
||||
const level = props.item.level
|
||||
|
||||
if (level && effectiveIconMap[level]) {
|
||||
return effectiveIconMap[level]
|
||||
}
|
||||
|
||||
return effectiveIconMap.default || '📄'
|
||||
}
|
||||
|
||||
return hasChildren.value ? '📁' : '📄'
|
||||
})
|
||||
|
||||
const isInPath = (childId) => {
|
||||
if (!props.inPath) return false
|
||||
return props.selectedId === childId
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren.value && props.config.expandable) {
|
||||
emit('toggle-expand', props.item.id)
|
||||
} else {
|
||||
emit('node-click', props.item)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cube-tree-item {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cube-tree-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cube-tree-item-header:hover {
|
||||
background-color: var(--cube-tree-item-hover-bg, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.cube-tree-item-header.selected {
|
||||
background-color: var(--cube-tree-item-selected-bg, #1890ff);
|
||||
color: var(--cube-tree-item-selected-color, #ffffff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cube-tree-item-header.in-path {
|
||||
background-color: var(--cube-tree-item-path-bg, rgba(24, 144, 255, 0.1));
|
||||
}
|
||||
|
||||
.cube-tree-item-header.in-path.selected {
|
||||
background-color: var(--cube-tree-item-selected-bg, #1890ff);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--cube-tree-item-icon-color, rgba(0, 0, 0, 0.45));
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.cube-tree-item-header.selected .expand-icon {
|
||||
color: var(--cube-tree-item-selected-color, #ffffff);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.level-prefix {
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--cube-tree-item-level-color, rgba(0, 0, 0, 0.45));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cube-tree-item-header.selected .level-prefix {
|
||||
color: var(--cube-tree-item-selected-color, #ffffff);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--cube-tree-item-title-color, inherit);
|
||||
}
|
||||
|
||||
.cube-tree-children {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
369
Web/Vue/CubeLib/src/components/CubeTreeView.vue
Normal file
369
Web/Vue/CubeLib/src/components/CubeTreeView.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="cube-tree-view" @keydown="handleKeydown" tabindex="0">
|
||||
<CubeTreeItem
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:depth="0"
|
||||
:selected="selectedId === item.id"
|
||||
:selected-id="selectedId"
|
||||
:config="config"
|
||||
:icon-map="iconMap"
|
||||
:in-path="isInPath(item.id)"
|
||||
@node-click="handleNodeClick"
|
||||
@toggle-expand="handleToggleExpand"
|
||||
>
|
||||
<template #icon="{ item }">
|
||||
<slot name="icon" :item="item"></slot>
|
||||
</template>
|
||||
<template #title="{ item }">
|
||||
<slot name="title" :item="item"></slot>
|
||||
</template>
|
||||
<template #prefix="{ item }">
|
||||
<slot name="prefix" :item="item"></slot>
|
||||
</template>
|
||||
<template #suffix="{ item }">
|
||||
<slot name="suffix" :item="item"></slot>
|
||||
</template>
|
||||
</CubeTreeItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import CubeTreeItem from './CubeTreeItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, default: () => [] },
|
||||
selectedId: { type: String, default: '' },
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
defaultExpandAll: false,
|
||||
showExpandIcon: true,
|
||||
indentSize: 16,
|
||||
expandable: true,
|
||||
iconType: 'custom',
|
||||
showLevel: false,
|
||||
levelPrefix: 'H',
|
||||
levelKey: 'level',
|
||||
autoExpand: false,
|
||||
highlightPath: false
|
||||
})
|
||||
},
|
||||
iconMap: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['node-click', 'toggle-expand'])
|
||||
|
||||
const treeViewRef = ref(null)
|
||||
const pathNodes = ref(new Set())
|
||||
|
||||
const handleNodeClick = (item) => {
|
||||
emit('node-click', item)
|
||||
}
|
||||
|
||||
const handleToggleExpand = (itemId) => {
|
||||
emit('toggle-expand', itemId)
|
||||
}
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
const items = getAllNodes(props.data)
|
||||
const currentIndex = items.findIndex(item => item.id === props.selectedId)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
selectNode(items[currentIndex - 1].id)
|
||||
}
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (currentIndex < items.length - 1) {
|
||||
selectNode(items[currentIndex + 1].id)
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
if (currentIndex >= 0) {
|
||||
const currentItem = items[currentIndex]
|
||||
if (currentItem.expanded && hasChildren(currentItem)) {
|
||||
collapseNode(currentItem.id)
|
||||
} else {
|
||||
const parent = findParent(currentItem.id)
|
||||
if (parent) {
|
||||
selectNode(parent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
if (currentIndex >= 0) {
|
||||
const currentItem = items[currentIndex]
|
||||
if (hasChildren(currentItem)) {
|
||||
if (!currentItem.expanded) {
|
||||
expandNode(currentItem.id)
|
||||
} else {
|
||||
const firstChild = currentItem.children[0]
|
||||
if (firstChild) {
|
||||
selectNode(firstChild.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (currentIndex >= 0) {
|
||||
handleNodeClick(items[currentIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getAllNodes = (nodes, result = []) => {
|
||||
nodes.forEach(node => {
|
||||
result.push(node)
|
||||
if (node.children && node.children.length > 0) {
|
||||
getAllNodes(node.children, result)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const hasChildren = (node) => {
|
||||
return node.children && node.children.length > 0
|
||||
}
|
||||
|
||||
const findParent = (id, nodes = props.data, parent = null) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return parent
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findParent(id, node.children, node)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const expandAll = () => {
|
||||
const expand = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.expanded = true
|
||||
expand(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
expand(props.data)
|
||||
}
|
||||
|
||||
const collapseAll = () => {
|
||||
const collapse = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.expanded = false
|
||||
collapse(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
collapse(props.data)
|
||||
}
|
||||
|
||||
const expandNode = (id) => {
|
||||
const findAndExpand = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
node.expanded = true
|
||||
return true
|
||||
}
|
||||
if (node.children && findAndExpand(node.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findAndExpand(props.data)
|
||||
}
|
||||
|
||||
const collapseNode = (id) => {
|
||||
const findAndCollapse = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
node.expanded = false
|
||||
return true
|
||||
}
|
||||
if (node.children && findAndCollapse(node.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findAndCollapse(props.data)
|
||||
}
|
||||
|
||||
const toggleNode = (id) => {
|
||||
const findAndToggle = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
node.expanded = !node.expanded
|
||||
return true
|
||||
}
|
||||
if (node.children && findAndToggle(node.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findAndToggle(props.data)
|
||||
}
|
||||
|
||||
const expandPath = (id) => {
|
||||
const path = []
|
||||
const findPath = (nodes, currentPath = []) => {
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node]
|
||||
if (node.id === id) {
|
||||
path.push(...newPath)
|
||||
return true
|
||||
}
|
||||
if (node.children && findPath(node.children, newPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findPath(props.data)
|
||||
|
||||
path.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.expanded = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const collapsePath = (id) => {
|
||||
const path = []
|
||||
const findPath = (nodes, currentPath = []) => {
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node]
|
||||
if (node.id === id) {
|
||||
path.push(...newPath)
|
||||
return true
|
||||
}
|
||||
if (node.children && findPath(node.children, newPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findPath(props.data)
|
||||
|
||||
path.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.expanded = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getNodeById = (id, nodes = props.data) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = getNodeById(id, node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getSelectedNode = () => {
|
||||
if (!props.selectedId) return null
|
||||
return getNodeById(props.selectedId)
|
||||
}
|
||||
|
||||
const selectNode = (id) => {
|
||||
const node = getNodeById(id)
|
||||
if (node) {
|
||||
if (props.config.autoExpand) {
|
||||
expandPath(id)
|
||||
}
|
||||
emit('node-click', node)
|
||||
}
|
||||
}
|
||||
|
||||
const isInPath = (id) => {
|
||||
if (!props.config.highlightPath || !props.selectedId) return false
|
||||
return pathNodes.value.has(id)
|
||||
}
|
||||
|
||||
const updatePathNodes = () => {
|
||||
if (!props.config.highlightPath || !props.selectedId) {
|
||||
pathNodes.value.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const path = []
|
||||
const findPath = (nodes, currentPath = []) => {
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node]
|
||||
if (node.id === props.selectedId) {
|
||||
path.push(...newPath)
|
||||
return true
|
||||
}
|
||||
if (node.children && findPath(node.children, newPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findPath(props.data)
|
||||
|
||||
pathNodes.value = new Set(path.map(node => node.id))
|
||||
}
|
||||
|
||||
watch(() => props.selectedId, () => {
|
||||
updatePathNodes()
|
||||
if (props.config.autoExpand && props.selectedId) {
|
||||
expandPath(props.selectedId)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updatePathNodes()
|
||||
if (props.config.defaultExpandAll) {
|
||||
expandAll()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({
|
||||
expandAll,
|
||||
collapseAll,
|
||||
expandNode,
|
||||
collapseNode,
|
||||
toggleNode,
|
||||
expandPath,
|
||||
collapsePath,
|
||||
getNodeById,
|
||||
getSelectedNode,
|
||||
selectNode
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cube-tree-view {
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cube-tree-view:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
103
Web/Vue/CubeLib/src/utils/treeDataConverter.js
Normal file
103
Web/Vue/CubeLib/src/utils/treeDataConverter.js
Normal file
@@ -0,0 +1,103 @@
|
||||
export class TreeDataConverter {
|
||||
static fromBackend(backendData, options = {}) {
|
||||
const {
|
||||
parentPath = '',
|
||||
removeExtension = true,
|
||||
defaultExpanded = false
|
||||
} = options
|
||||
|
||||
return backendData.map(item => {
|
||||
const fullPath = parentPath ? `${parentPath}/${item.name}` : item.name
|
||||
const title = removeExtension && item.name.endsWith('.md')
|
||||
? item.name.slice(0, -3)
|
||||
: item.name
|
||||
|
||||
return {
|
||||
id: fullPath,
|
||||
title: title,
|
||||
expanded: defaultExpanded || item.is_directory,
|
||||
children: item.children
|
||||
? this.fromBackend(item.children, {
|
||||
parentPath: fullPath,
|
||||
removeExtension,
|
||||
defaultExpanded
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static fromFlatArray(flatData, options = {}) {
|
||||
const {
|
||||
levelKey = 'level',
|
||||
defaultExpanded = true
|
||||
} = options
|
||||
|
||||
if (!Array.isArray(flatData) || flatData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = []
|
||||
const path = []
|
||||
|
||||
flatData.forEach(item => {
|
||||
const currentLevel = item[levelKey]
|
||||
|
||||
while (path.length > 0 && path[path.length - 1].level >= currentLevel) {
|
||||
path.pop()
|
||||
}
|
||||
|
||||
const treeNode = {
|
||||
...item,
|
||||
expanded: defaultExpanded,
|
||||
children: [],
|
||||
level: currentLevel
|
||||
}
|
||||
|
||||
if (path.length === 0) {
|
||||
result.push(treeNode)
|
||||
} else {
|
||||
path[path.length - 1].children.push(treeNode)
|
||||
}
|
||||
|
||||
path.push(treeNode)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static validate(data) {
|
||||
const errors = []
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
errors.push('数据必须是数组')
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
const validateNode = (node, path = '') => {
|
||||
if (!node.id) {
|
||||
errors.push(`节点缺少id字段: ${path}`)
|
||||
}
|
||||
if (!node.title) {
|
||||
errors.push(`节点缺少title字段: ${path}`)
|
||||
}
|
||||
if (node.children && !Array.isArray(node.children)) {
|
||||
errors.push(`节点的children必须是数组: ${path}`)
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child, index) => {
|
||||
validateNode(child, `${path}/${node.title}[${index}]`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data.forEach((node, index) => {
|
||||
validateNode(node, `[${index}]`)
|
||||
})
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user