TreeNode.vue 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <script setup lang="ts">
  2. import { computed } from 'vue'
  3. import type { CategoryNode, WeightDimKey } from '../types/category'
  4. import {
  5. formatAvgScore,
  6. heatColor,
  7. heatTextColor,
  8. weightHeatT,
  9. } from '../types/category'
  10. import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
  11. const props = defineProps<{
  12. node: CategoryNode
  13. depth: number
  14. expandDepth: number
  15. expandedMap: Record<number, true>
  16. collapsedMap: Record<number, true>
  17. demandsByCategory: DemandsByCategory
  18. activeDim: WeightDimKey | null
  19. weightScale: number[]
  20. }>()
  21. const emit = defineEmits<{
  22. toggle: [id: number]
  23. inspect: [payload: { categoryId: number; categoryName: string; items: DemandBelongItem[] }]
  24. }>()
  25. const hasChildren = () => props.node.children.length > 0
  26. function isExpanded(): boolean {
  27. if (!hasChildren()) return false
  28. if (props.collapsedMap[props.node.id]) return false
  29. if (props.expandedMap[props.node.id]) return true
  30. return props.depth < props.expandDepth
  31. }
  32. function demandsForNode(): DemandBelongItem[] {
  33. return props.demandsByCategory[props.node.id] ?? []
  34. }
  35. const weight = computed(() =>
  36. props.activeDim ? (props.node.weights?.[props.activeDim] ?? 0) : null,
  37. )
  38. const count = computed(() =>
  39. props.activeDim ? (props.node.counts?.[props.activeDim] ?? 0) : 0,
  40. )
  41. const heatT = computed(() => {
  42. if (!props.activeDim || count.value <= 0 || weight.value == null) return null
  43. return weightHeatT(weight.value, props.weightScale)
  44. })
  45. const cardStyle = computed(() => {
  46. const t = heatT.value
  47. const bg = heatColor(t)
  48. const color = heatTextColor(t)
  49. return {
  50. background: bg,
  51. color,
  52. borderColor: t == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)',
  53. }
  54. })
  55. function onInspect(e: MouseEvent) {
  56. e.stopPropagation()
  57. const items = demandsForNode()
  58. emit('inspect', {
  59. categoryId: props.node.id,
  60. categoryName: props.node.name || '(未命名)',
  61. items,
  62. })
  63. }
  64. </script>
  65. <template>
  66. <div class="tree-node">
  67. <div
  68. class="node-card"
  69. :class="{ leaf: !hasChildren(), open: isExpanded(), muted: activeDim != null && heatT == null }"
  70. :style="cardStyle"
  71. :title="
  72. !activeDim
  73. ? (node.name || '(未命名)')
  74. : count > 0
  75. ? `avg=${weight} count=${count}`
  76. : '无数据'
  77. "
  78. >
  79. <button
  80. v-if="hasChildren()"
  81. class="toggle"
  82. type="button"
  83. :aria-expanded="isExpanded()"
  84. :title="isExpanded() ? '收起' : '展开'"
  85. @click="emit('toggle', node.id)"
  86. >
  87. {{ isExpanded() ? '−' : '+' }}
  88. </button>
  89. <span v-else class="toggle-spacer" />
  90. <div class="node-main">
  91. <div class="node-text">
  92. <span class="node-name">{{ node.name || '(未命名)' }}</span>
  93. <span v-if="activeDim" class="node-weight">
  94. {{ formatAvgScore(weight, count) }}
  95. </span>
  96. </div>
  97. <button
  98. v-if="demandsForNode().length"
  99. class="inspect"
  100. type="button"
  101. title="查看需求词"
  102. @click="onInspect"
  103. >
  104. 🔍
  105. </button>
  106. </div>
  107. </div>
  108. <ul v-if="hasChildren() && isExpanded()" class="children">
  109. <li
  110. v-for="child in node.children"
  111. :key="child.id"
  112. class="child"
  113. >
  114. <TreeNode
  115. :node="child"
  116. :depth="depth + 1"
  117. :expand-depth="expandDepth"
  118. :expanded-map="expandedMap"
  119. :collapsed-map="collapsedMap"
  120. :demands-by-category="demandsByCategory"
  121. :active-dim="activeDim"
  122. :weight-scale="weightScale"
  123. @toggle="emit('toggle', $event)"
  124. @inspect="emit('inspect', $event)"
  125. />
  126. </li>
  127. </ul>
  128. </div>
  129. </template>
  130. <style scoped>
  131. .tree-node {
  132. --line-y: 20px;
  133. --line-color: #cbd5e1;
  134. --gap: var(--col-gap, 36px);
  135. display: flex;
  136. flex-direction: row;
  137. align-items: flex-start;
  138. }
  139. .node-card {
  140. display: flex;
  141. align-items: center;
  142. gap: 8px;
  143. width: var(--col-width, 180px);
  144. min-height: 40px;
  145. padding: 8px 10px;
  146. border: 1px solid #d8dee6;
  147. border-radius: 8px;
  148. box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
  149. flex-shrink: 0;
  150. box-sizing: border-box;
  151. transition: background 0.15s ease, border-color 0.15s ease;
  152. }
  153. .node-card.muted {
  154. opacity: 0.72;
  155. }
  156. .toggle {
  157. width: 22px;
  158. height: 22px;
  159. border: 1px solid rgba(15, 23, 42, 0.18);
  160. border-radius: 4px;
  161. background: rgba(255, 255, 255, 0.65);
  162. color: #334155;
  163. font-size: 14px;
  164. line-height: 1;
  165. cursor: pointer;
  166. flex-shrink: 0;
  167. padding: 0;
  168. }
  169. .toggle:hover {
  170. border-color: #64748b;
  171. background: #fff;
  172. }
  173. .toggle-spacer {
  174. width: 22px;
  175. flex-shrink: 0;
  176. }
  177. .node-main {
  178. flex: 1;
  179. min-width: 0;
  180. display: flex;
  181. align-items: flex-start;
  182. gap: 4px;
  183. }
  184. .node-text {
  185. flex: 1;
  186. min-width: 0;
  187. display: flex;
  188. flex-direction: column;
  189. gap: 2px;
  190. }
  191. .node-name {
  192. font-size: 13px;
  193. font-weight: 600;
  194. line-height: 1.3;
  195. word-break: break-word;
  196. }
  197. .node-weight {
  198. font-size: 11px;
  199. font-weight: 600;
  200. font-variant-numeric: tabular-nums;
  201. opacity: 0.85;
  202. letter-spacing: 0.01em;
  203. }
  204. .inspect {
  205. flex-shrink: 0;
  206. width: 24px;
  207. height: 24px;
  208. margin-top: -1px;
  209. border: none;
  210. border-radius: 4px;
  211. background: transparent;
  212. font-size: 13px;
  213. line-height: 1;
  214. cursor: pointer;
  215. padding: 0;
  216. }
  217. .inspect:hover {
  218. background: rgba(15, 23, 42, 0.08);
  219. }
  220. .children {
  221. --half-gap: calc(var(--gap) / 2);
  222. --sibling-gap: 10px;
  223. display: flex;
  224. flex-direction: column;
  225. list-style: none;
  226. margin: 0;
  227. padding: 0 0 0 var(--gap);
  228. position: relative;
  229. }
  230. .children::before {
  231. content: '';
  232. position: absolute;
  233. left: 0;
  234. top: var(--line-y);
  235. width: var(--half-gap);
  236. border-top: 2px solid var(--line-color);
  237. }
  238. .child {
  239. position: relative;
  240. }
  241. .child:not(:last-child) {
  242. padding-bottom: var(--sibling-gap);
  243. }
  244. .child::before {
  245. content: '';
  246. position: absolute;
  247. top: var(--line-y);
  248. left: 0;
  249. width: var(--half-gap);
  250. margin-left: calc(-1 * var(--half-gap));
  251. border-top: 2px solid var(--line-color);
  252. }
  253. .child::after {
  254. content: '';
  255. position: absolute;
  256. left: 0;
  257. margin-left: calc(-1 * var(--half-gap));
  258. border-left: 2px solid var(--line-color);
  259. }
  260. .child:not(:first-child):not(:last-child)::after {
  261. top: 0;
  262. bottom: 0;
  263. }
  264. .child:first-child:not(:last-child)::after {
  265. top: var(--line-y);
  266. bottom: 0;
  267. }
  268. .child:last-child:not(:first-child)::after {
  269. top: 0;
  270. height: var(--line-y);
  271. }
  272. .child:only-child::after {
  273. display: none;
  274. }
  275. </style>