TreeNode.vue 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. <script setup lang="ts">
  2. import type { CategoryNode } from '../types/category'
  3. const props = defineProps<{
  4. node: CategoryNode
  5. depth: number
  6. expandDepth: number
  7. expandedMap: Record<number, true>
  8. collapsedMap: Record<number, true>
  9. }>()
  10. const emit = defineEmits<{
  11. toggle: [id: number]
  12. }>()
  13. const hasChildren = () => props.node.children.length > 0
  14. function isExpanded(): boolean {
  15. if (!hasChildren()) return false
  16. if (props.collapsedMap[props.node.id]) return false
  17. if (props.expandedMap[props.node.id]) return true
  18. return props.depth < props.expandDepth
  19. }
  20. </script>
  21. <template>
  22. <div class="tree-node">
  23. <div class="node-card" :class="{ leaf: !hasChildren(), open: isExpanded() }">
  24. <button
  25. v-if="hasChildren()"
  26. class="toggle"
  27. type="button"
  28. :aria-expanded="isExpanded()"
  29. :title="isExpanded() ? '收起' : '展开'"
  30. @click="emit('toggle', node.id)"
  31. >
  32. {{ isExpanded() ? '−' : '+' }}
  33. </button>
  34. <span v-else class="toggle-spacer" />
  35. <div class="node-name">{{ node.name || '(未命名)' }}</div>
  36. </div>
  37. <ul v-if="hasChildren() && isExpanded()" class="children">
  38. <li
  39. v-for="child in node.children"
  40. :key="child.id"
  41. class="child"
  42. >
  43. <TreeNode
  44. :node="child"
  45. :depth="depth + 1"
  46. :expand-depth="expandDepth"
  47. :expanded-map="expandedMap"
  48. :collapsed-map="collapsedMap"
  49. @toggle="emit('toggle', $event)"
  50. />
  51. </li>
  52. </ul>
  53. </div>
  54. </template>
  55. <style scoped>
  56. /* 节点卡片垂直中线,用于对齐连线 */
  57. .tree-node {
  58. --line-y: 20px;
  59. --line-color: #cbd5e1;
  60. --gap: var(--col-gap, 36px);
  61. display: flex;
  62. flex-direction: row;
  63. align-items: flex-start;
  64. }
  65. .node-card {
  66. display: flex;
  67. align-items: center;
  68. gap: 8px;
  69. width: var(--col-width, 180px);
  70. min-height: 40px;
  71. padding: 8px 10px;
  72. border: 1px solid #d8dee6;
  73. border-radius: 8px;
  74. background: #fff;
  75. box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
  76. flex-shrink: 0;
  77. box-sizing: border-box;
  78. }
  79. .node-card.open {
  80. border-color: #9eb6d4;
  81. background: #f7faff;
  82. }
  83. .node-card.leaf {
  84. border-style: dashed;
  85. background: #fafbfc;
  86. }
  87. .toggle {
  88. width: 22px;
  89. height: 22px;
  90. border: 1px solid #c5ced9;
  91. border-radius: 4px;
  92. background: #fff;
  93. color: #334155;
  94. font-size: 14px;
  95. line-height: 1;
  96. cursor: pointer;
  97. flex-shrink: 0;
  98. padding: 0;
  99. }
  100. .toggle:hover {
  101. border-color: #64748b;
  102. background: #f1f5f9;
  103. }
  104. .toggle-spacer {
  105. width: 22px;
  106. flex-shrink: 0;
  107. }
  108. .node-name {
  109. flex: 1;
  110. min-width: 0;
  111. font-size: 14px;
  112. font-weight: 600;
  113. color: #0f172a;
  114. line-height: 1.35;
  115. word-break: break-word;
  116. }
  117. .children {
  118. --half-gap: calc(var(--gap) / 2);
  119. --sibling-gap: 10px;
  120. display: flex;
  121. flex-direction: column;
  122. /* 不用 flex gap:空隙不在子项盒模型内,竖线会断开 */
  123. list-style: none;
  124. margin: 0;
  125. padding: 0 0 0 var(--gap);
  126. position: relative;
  127. }
  128. /* 父节点 → 竖脊:左半段水平线 */
  129. .children::before {
  130. content: '';
  131. position: absolute;
  132. left: 0;
  133. top: var(--line-y);
  134. width: var(--half-gap);
  135. border-top: 2px solid var(--line-color);
  136. }
  137. .child {
  138. position: relative;
  139. }
  140. /* 间距放进 padding,让竖脊 ::after 能连续穿过 */
  141. .child:not(:last-child) {
  142. padding-bottom: var(--sibling-gap);
  143. }
  144. /* 竖脊 → 子节点:右半段水平线 */
  145. .child::before {
  146. content: '';
  147. position: absolute;
  148. top: var(--line-y);
  149. left: 0;
  150. width: var(--half-gap);
  151. margin-left: calc(-1 * var(--half-gap));
  152. border-top: 2px solid var(--line-color);
  153. }
  154. /* 兄弟之间的竖脊(位于 gap 中点) */
  155. .child::after {
  156. content: '';
  157. position: absolute;
  158. left: 0;
  159. margin-left: calc(-1 * var(--half-gap));
  160. border-left: 2px solid var(--line-color);
  161. }
  162. /* 中间兄弟:通栏竖线(含底部 padding) */
  163. .child:not(:first-child):not(:last-child)::after {
  164. top: 0;
  165. bottom: 0;
  166. }
  167. /* 第一个兄弟:从中线画到自身底部(含 padding) */
  168. .child:first-child:not(:last-child)::after {
  169. top: var(--line-y);
  170. bottom: 0;
  171. }
  172. /* 最后一个兄弟:从顶部画到中线 */
  173. .child:last-child:not(:first-child)::after {
  174. top: 0;
  175. height: var(--line-y);
  176. }
  177. /* 唯一子节点:无需竖脊(水平线已贯通) */
  178. .child:only-child::after {
  179. display: none;
  180. }
  181. </style>