TreeNode.vue 7.2 KB

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