添加树结构组件
This commit is contained in:
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
所有重要的项目更改都将记录在此文件中。
|
所有重要的项目更改都将记录在此文件中。
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-01-30
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增 `CubeTreeView` 组件文档
|
||||||
|
- 添加了完整的 Props 说明
|
||||||
|
- 添加了 Config 配置对象说明
|
||||||
|
- 添加了 Events、Methods、Slots 说明
|
||||||
|
- 添加了使用示例
|
||||||
|
- 添加了键盘导航说明
|
||||||
|
- 添加了 CSS 变量说明
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 更新 `package.json` 版本号至 1.2.2
|
||||||
|
- 更新 `README.md`,添加 `CubeTreeView` 组件文档
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-01-30
|
||||||
|
|
||||||
|
### 移除
|
||||||
|
|
||||||
|
- 移除 `CubeButton` 组件
|
||||||
|
- 删除了按钮组件的实现
|
||||||
|
- 从组件列表中移除注册和导出
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 更新 `package.json` 版本号至 1.2.1
|
||||||
|
- 更新 `index.ts`,只保留 `CubeSplitter` 组件
|
||||||
|
- 更新 `README.md`,移除 `CubeButton` 组件文档
|
||||||
|
- 修复 `vite.config.js`,添加 `exports: 'named'` 配置
|
||||||
|
- 修复构建警告,解决默认导出和命名导出混合的问题
|
||||||
|
- 更新 `package.json`,移除不存在的 `vue-demi-vite` 依赖
|
||||||
|
- 更新 `package.json`,将构建命令从 `vue-demi-vite build` 改为 `vite build`
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 简化组件库结构,专注于 `CubeSplitter` 组件
|
||||||
|
- 优化构建配置,提高构建稳定性
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-30
|
## [1.1.0] - 2026-01-30
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -305,6 +305,196 @@ const handleStatusChanged = (newStatus) => {
|
|||||||
- [自动重连示例](examples/CubeWebSocket/AutoReconnectExample.vue)
|
- [自动重连示例](examples/CubeWebSocket/AutoReconnectExample.vue)
|
||||||
- [消息队列示例](examples/CubeWebSocket/MessageQueueExample.vue)
|
- [消息队列示例](examples/CubeWebSocket/MessageQueueExample.vue)
|
||||||
|
|
||||||
|
### CubeTreeView
|
||||||
|
|
||||||
|
树形视图组件,用于展示层级数据结构,支持展开/折叠、键盘导航、自定义样式等功能。
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| 参数 | 说明 | 类型 | 默认值 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| data | 树形数据数组 | Array | [] |
|
||||||
|
| selectedId | 当前选中的节点ID | String | '' |
|
||||||
|
| config | 组件配置对象 | Object | 见下方配置说明 |
|
||||||
|
| iconMap | 自定义图标映射 | Object | - |
|
||||||
|
|
||||||
|
#### Config 配置对象
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 类型 | 默认值 |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| defaultExpandAll | 是否默认展开所有节点 | Boolean | false |
|
||||||
|
| showExpandIcon | 是否显示展开/折叠图标 | Boolean | true |
|
||||||
|
| indentSize | 缩进大小(像素) | Number | 16 |
|
||||||
|
| expandable | 是否允许展开/折叠 | Boolean | true |
|
||||||
|
| iconType | 图标类型:'custom'、'file'、'heading' | String | 'custom' |
|
||||||
|
| showLevel | 是否显示层级前缀 | Boolean | false |
|
||||||
|
| levelPrefix | 层级前缀文本 | String | 'H' |
|
||||||
|
| levelKey | 层级字段名 | String | 'level' |
|
||||||
|
| autoExpand | 选中节点时是否自动展开路径 | Boolean | false |
|
||||||
|
| highlightPath | 是否高亮选中节点的路径 | Boolean | false |
|
||||||
|
|
||||||
|
#### Events
|
||||||
|
|
||||||
|
| 事件名 | 说明 | 参数 |
|
||||||
|
|--------|------|------|
|
||||||
|
| node-click | 节点被点击 | item |
|
||||||
|
| toggle-expand | 节点展开/折叠状态变化 | itemId |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| 方法名 | 说明 | 参数 | 返回值 |
|
||||||
|
|--------|------|------|--------|
|
||||||
|
| expandAll | 展开所有节点 | - | void |
|
||||||
|
| collapseAll | 折叠所有节点 | - | void |
|
||||||
|
| expandNode | 展开指定节点 | id | void |
|
||||||
|
| collapseNode | 折叠指定节点 | id | void |
|
||||||
|
| toggleNode | 切换节点展开/折叠状态 | id | void |
|
||||||
|
| expandPath | 展开到指定节点的路径 | id | void |
|
||||||
|
| collapsePath | 折叠到指定节点的路径 | id | void |
|
||||||
|
| getNodeById | 根据ID获取节点 | id | Object |
|
||||||
|
| getSelectedNode | 获取当前选中的节点 | - | Object |
|
||||||
|
| selectNode | 选中指定节点 | id | void |
|
||||||
|
|
||||||
|
#### Slots
|
||||||
|
|
||||||
|
| 插槽名 | 说明 | 作用域参数 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| icon | 自定义节点图标 | { item } |
|
||||||
|
| title | 自定义节点标题 | { item } |
|
||||||
|
| prefix | 自定义节点前缀 | { item } |
|
||||||
|
| suffix | 自定义节点后缀 | { item } |
|
||||||
|
|
||||||
|
#### 使用示例
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="tree-container">
|
||||||
|
<CubeTreeView
|
||||||
|
ref="treeViewRef"
|
||||||
|
:data="treeData"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
:config="treeConfig"
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
@toggle-expand="handleToggleExpand"
|
||||||
|
>
|
||||||
|
<template #icon="{ item }">
|
||||||
|
<span class="custom-icon">{{ item.icon }}</span>
|
||||||
|
</template>
|
||||||
|
<template #title="{ item }">
|
||||||
|
<span class="custom-title">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</CubeTreeView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { CubeTreeView } from 'joyd.web.vue.cubelib'
|
||||||
|
|
||||||
|
const treeViewRef = ref(null)
|
||||||
|
const selectedId = ref('')
|
||||||
|
|
||||||
|
const treeData = ref([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: '根节点1',
|
||||||
|
level: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '1-1',
|
||||||
|
title: '子节点1-1',
|
||||||
|
level: 2,
|
||||||
|
children: [
|
||||||
|
{ id: '1-1-1', title: '叶子节点1-1-1', level: 3 },
|
||||||
|
{ id: '1-1-2', title: '叶子节点1-1-2', level: 3 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1-2',
|
||||||
|
title: '子节点1-2',
|
||||||
|
level: 2,
|
||||||
|
children: [
|
||||||
|
{ id: '1-2-1', title: '叶子节点1-2-1', level: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: '根节点2',
|
||||||
|
level: 1,
|
||||||
|
children: [
|
||||||
|
{ id: '2-1', title: '子节点2-1', level: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const treeConfig = {
|
||||||
|
defaultExpandAll: false,
|
||||||
|
showExpandIcon: true,
|
||||||
|
indentSize: 16,
|
||||||
|
expandable: true,
|
||||||
|
iconType: 'custom',
|
||||||
|
showLevel: false,
|
||||||
|
autoExpand: true,
|
||||||
|
highlightPath: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeClick = (item) => {
|
||||||
|
console.log('节点被点击:', item)
|
||||||
|
selectedId.value = item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleExpand = (itemId) => {
|
||||||
|
console.log('节点展开/折叠:', itemId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-container {
|
||||||
|
width: 300px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 键盘导航
|
||||||
|
|
||||||
|
CubeTreeView 支持以下键盘操作:
|
||||||
|
|
||||||
|
- `↑` - 向上移动选择
|
||||||
|
- `↓` - 向下移动选择
|
||||||
|
- `←` - 折叠当前节点或选择父节点
|
||||||
|
- `→` - 展开当前节点或选择第一个子节点
|
||||||
|
- `Enter` / `Space` - 触发节点点击事件
|
||||||
|
|
||||||
|
#### CSS 变量
|
||||||
|
|
||||||
|
CubeTreeView 组件支持通过 CSS 变量进行定制:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--cube-tree-item-hover-bg: rgba(0, 0, 0, 0.05);
|
||||||
|
--cube-tree-item-selected-bg: #1890ff;
|
||||||
|
--cube-tree-item-selected-color: #ffffff;
|
||||||
|
--cube-tree-item-path-bg: rgba(24, 144, 255, 0.1);
|
||||||
|
--cube-tree-item-icon-color: rgba(0, 0, 0, 0.45);
|
||||||
|
--cube-tree-item-level-color: rgba(0, 0, 0, 0.45);
|
||||||
|
--cube-tree-item-title-color: inherit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 常见问题解答
|
## 常见问题解答
|
||||||
|
|
||||||
### Q: 如何保存分隔条的位置?
|
### Q: 如何保存分隔条的位置?
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import CubeSplitter from './src/components/CubeSplitter.vue'
|
import CubeSplitter from './src/components/CubeSplitter.vue'
|
||||||
import CubeWebSocket from './src/components/CubeWebSocket.vue'
|
import CubeWebSocket from './src/components/CubeWebSocket.vue'
|
||||||
|
import CubeTreeView from './src/components/CubeTreeView.vue'
|
||||||
|
import CubeTreeItem from './src/components/CubeTreeItem.vue'
|
||||||
|
import { TreeDataConverter } from './src/utils/treeDataConverter.js'
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
CubeSplitter,
|
CubeSplitter,
|
||||||
CubeWebSocket
|
CubeWebSocket,
|
||||||
|
CubeTreeView,
|
||||||
|
CubeTreeItem
|
||||||
}
|
}
|
||||||
|
|
||||||
const CubeLib = {
|
const CubeLib = {
|
||||||
@@ -16,4 +21,4 @@ const CubeLib = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default CubeLib
|
export default CubeLib
|
||||||
export { CubeSplitter, CubeWebSocket }
|
export { CubeSplitter, CubeWebSocket, CubeTreeView, CubeTreeItem, TreeDataConverter }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "joyd.web.vue.cubelib",
|
"name": "joyd.web.vue.cubelib",
|
||||||
"version": "1.1.1",
|
"version": "1.2.2",
|
||||||
"description": "Vue3 CubeLib 组件库",
|
"description": "Vue3 CubeLib 组件库 - 包含WebSocket、Splitter、TreeView等组件",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": ">=3.3.0"
|
"vue": ">=3.3.0"
|
||||||
},
|
},
|
||||||
"keywords": ["vue3", "components", "cubelib", "joyd"],
|
"keywords": ["vue3", "components", "cubelib", "joyd", "websocket", "splitter", "treeview"],
|
||||||
"author": "JoyD",
|
"author": "JoyD",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
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