|
|
@@ -1,11 +1,14 @@
|
|
|
<script setup lang="ts">
|
|
|
import { computed, ref, watch } from 'vue'
|
|
|
import TreeNode from './TreeNode.vue'
|
|
|
+import DemandDrawer from './DemandDrawer.vue'
|
|
|
import type { CategoryNode } from '../types/category'
|
|
|
import { maxTreeDepth } from '../types/category'
|
|
|
+import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
nodes: CategoryNode[]
|
|
|
+ demandsByCategory: DemandsByCategory
|
|
|
}>()
|
|
|
|
|
|
const DEFAULT_DEPTH = 3
|
|
|
@@ -16,6 +19,17 @@ const expandDepth = ref(DEFAULT_DEPTH)
|
|
|
const expandedMap = ref<Record<number, true>>({})
|
|
|
const collapsedMap = ref<Record<number, true>>({})
|
|
|
|
|
|
+const drawerOpen = ref(false)
|
|
|
+const drawerCategoryName = ref('')
|
|
|
+const drawerItems = ref<DemandBelongItem[]>([])
|
|
|
+
|
|
|
+const treePanelRef = ref<HTMLElement | null>(null)
|
|
|
+const isPanning = ref(false)
|
|
|
+let panStartX = 0
|
|
|
+let panStartY = 0
|
|
|
+let panScrollLeft = 0
|
|
|
+let panScrollTop = 0
|
|
|
+
|
|
|
const treeMaxDepth = computed(() => maxTreeDepth(props.nodes))
|
|
|
|
|
|
const depthOptions = computed(() => {
|
|
|
@@ -79,6 +93,51 @@ function toggleNode(id: number) {
|
|
|
function expandAll() {
|
|
|
expandDepth.value = treeMaxDepth.value || 1
|
|
|
}
|
|
|
+
|
|
|
+function onInspect(payload: {
|
|
|
+ categoryId: number
|
|
|
+ categoryName: string
|
|
|
+ items: DemandBelongItem[]
|
|
|
+}) {
|
|
|
+ drawerCategoryName.value = payload.categoryName
|
|
|
+ drawerItems.value = payload.items
|
|
|
+ drawerOpen.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function closeDrawer() {
|
|
|
+ drawerOpen.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function onPanStart(e: MouseEvent) {
|
|
|
+ const el = treePanelRef.value
|
|
|
+ if (!el || e.button !== 0) return
|
|
|
+ // 点在按钮上时不启动拖拽,避免和展开/查看冲突
|
|
|
+ const target = e.target as HTMLElement | null
|
|
|
+ if (target?.closest('button, select, a, input')) return
|
|
|
+
|
|
|
+ isPanning.value = true
|
|
|
+ panStartX = e.clientX
|
|
|
+ panStartY = e.clientY
|
|
|
+ panScrollLeft = el.scrollLeft
|
|
|
+ panScrollTop = el.scrollTop
|
|
|
+ el.classList.add('is-panning')
|
|
|
+ window.addEventListener('mousemove', onPanMove)
|
|
|
+ window.addEventListener('mouseup', onPanEnd)
|
|
|
+}
|
|
|
+
|
|
|
+function onPanMove(e: MouseEvent) {
|
|
|
+ const el = treePanelRef.value
|
|
|
+ if (!el || !isPanning.value) return
|
|
|
+ el.scrollLeft = panScrollLeft - (e.clientX - panStartX)
|
|
|
+ el.scrollTop = panScrollTop - (e.clientY - panStartY)
|
|
|
+}
|
|
|
+
|
|
|
+function onPanEnd() {
|
|
|
+ isPanning.value = false
|
|
|
+ treePanelRef.value?.classList.remove('is-panning')
|
|
|
+ window.removeEventListener('mousemove', onPanMove)
|
|
|
+ window.removeEventListener('mouseup', onPanEnd)
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
@@ -97,34 +156,52 @@ function expandAll() {
|
|
|
<span>层</span>
|
|
|
</label>
|
|
|
<button type="button" class="btn" @click="expandAll">全部展开</button>
|
|
|
- <span class="hint">共 {{ treeMaxDepth }} 层 · 点击节点旁 +/− 手动展开/收起</span>
|
|
|
+ <span class="hint">
|
|
|
+ 共 {{ treeMaxDepth }} 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚
|
|
|
+ </span>
|
|
|
</div>
|
|
|
</header>
|
|
|
|
|
|
<div v-if="!nodes.length" class="empty">暂无分类数据</div>
|
|
|
- <div v-else class="tree-panel">
|
|
|
- <div class="level-headers">
|
|
|
- <div
|
|
|
- v-for="n in headerLevels"
|
|
|
- :key="n"
|
|
|
- class="level-header"
|
|
|
- >
|
|
|
- 第 {{ n }} 层
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ ref="treePanelRef"
|
|
|
+ class="tree-panel"
|
|
|
+ @mousedown="onPanStart"
|
|
|
+ >
|
|
|
+ <div class="tree-scroll-inner">
|
|
|
+ <div class="level-headers">
|
|
|
+ <div
|
|
|
+ v-for="n in headerLevels"
|
|
|
+ :key="n"
|
|
|
+ class="level-header"
|
|
|
+ >
|
|
|
+ 第 {{ n }} 层
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="forest">
|
|
|
+ <TreeNode
|
|
|
+ v-for="node in nodes"
|
|
|
+ :key="node.id"
|
|
|
+ :node="node"
|
|
|
+ :depth="1"
|
|
|
+ :expand-depth="expandDepth"
|
|
|
+ :expanded-map="expandedMap"
|
|
|
+ :collapsed-map="collapsedMap"
|
|
|
+ :demands-by-category="demandsByCategory"
|
|
|
+ @toggle="toggleNode"
|
|
|
+ @inspect="onInspect"
|
|
|
+ />
|
|
|
</div>
|
|
|
- </div>
|
|
|
- <div class="forest">
|
|
|
- <TreeNode
|
|
|
- v-for="node in nodes"
|
|
|
- :key="node.id"
|
|
|
- :node="node"
|
|
|
- :depth="1"
|
|
|
- :expand-depth="expandDepth"
|
|
|
- :expanded-map="expandedMap"
|
|
|
- :collapsed-map="collapsedMap"
|
|
|
- @toggle="toggleNode"
|
|
|
- />
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
+ <DemandDrawer
|
|
|
+ :open="drawerOpen"
|
|
|
+ :category-name="drawerCategoryName"
|
|
|
+ :items="drawerItems"
|
|
|
+ @close="closeDrawer"
|
|
|
+ />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -132,8 +209,10 @@ function expandAll() {
|
|
|
.category-tree {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 20px;
|
|
|
- min-height: 100%;
|
|
|
+ gap: 16px;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ max-width: 100%;
|
|
|
}
|
|
|
|
|
|
.toolbar {
|
|
|
@@ -144,6 +223,7 @@ function expandAll() {
|
|
|
gap: 12px 24px;
|
|
|
padding-bottom: 16px;
|
|
|
border-bottom: 1px solid #e2e8f0;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
.toolbar h1 {
|
|
|
@@ -200,8 +280,46 @@ function expandAll() {
|
|
|
}
|
|
|
|
|
|
.tree-panel {
|
|
|
- overflow: auto;
|
|
|
- padding: 0 4px 32px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ min-width: 0;
|
|
|
+ width: 100%;
|
|
|
+ overflow: scroll;
|
|
|
+ overscroll-behavior: contain;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(255, 255, 255, 0.55);
|
|
|
+ cursor: grab;
|
|
|
+ scrollbar-gutter: stable;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel.is-panning {
|
|
|
+ cursor: grabbing;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel::-webkit-scrollbar {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel::-webkit-scrollbar-thumb {
|
|
|
+ background: #94a3b8;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 2px solid transparent;
|
|
|
+ background-clip: content-box;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel::-webkit-scrollbar-track {
|
|
|
+ background: #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-scroll-inner {
|
|
|
+ display: inline-block;
|
|
|
+ min-width: 100%;
|
|
|
+ padding: 8px 12px 24px;
|
|
|
+ vertical-align: top;
|
|
|
}
|
|
|
|
|
|
.level-headers {
|
|
|
@@ -211,11 +329,13 @@ function expandAll() {
|
|
|
position: sticky;
|
|
|
top: 0;
|
|
|
z-index: 2;
|
|
|
- background: rgba(241, 245, 249, 0.92);
|
|
|
+ background: rgba(248, 250, 252, 0.96);
|
|
|
backdrop-filter: blur(6px);
|
|
|
border-bottom: 1px solid #e2e8f0;
|
|
|
margin-bottom: 12px;
|
|
|
padding: 8px 0;
|
|
|
+ width: max-content;
|
|
|
+ min-width: 100%;
|
|
|
}
|
|
|
|
|
|
.level-header {
|
|
|
@@ -238,6 +358,8 @@ function expandAll() {
|
|
|
flex-direction: column;
|
|
|
align-items: flex-start;
|
|
|
gap: 16px;
|
|
|
+ width: max-content;
|
|
|
+ min-width: 100%;
|
|
|
}
|
|
|
|
|
|
.empty {
|