TreeNode.vue 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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. formatWeightScore,
  7. heatColor,
  8. heatTextColor,
  9. scoreHeatT,
  10. } from '../types/category'
  11. import type { DemandGradeItem, 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: DemandGradeItem[]
  24. }>()
  25. const emit = defineEmits<{
  26. toggle: [id: number]
  27. inspect: [payload: { categoryId: number; categoryName: string; items: DemandGradeItem[] }]
  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(): DemandGradeItem[] {
  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] ?? null) : 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 scoreHeatT(props.activeDim, 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. ? activeDim === 'total_score'
  87. ? `全局热度=${weight} 覆盖维度=${count}/4`
  88. : `avg=${weight} count=${count}`
  89. : '无数据'
  90. "
  91. >
  92. <button
  93. v-if="hasChildren()"
  94. class="toggle"
  95. type="button"
  96. :aria-expanded="isExpanded()"
  97. :title="isExpanded() ? '收起' : '展开'"
  98. @click="emit('toggle', node.id)"
  99. >
  100. {{ isExpanded() ? '−' : '+' }}
  101. </button>
  102. <span v-else class="toggle-spacer" />
  103. <div class="node-main">
  104. <div class="node-text">
  105. <span class="node-name">{{ node.name || '(未命名)' }}</span>
  106. <span v-if="activeDim" class="node-weight">
  107. {{ formatWeightScore(activeDim, weight, count) }}
  108. </span>
  109. </div>
  110. <button
  111. v-if="demandsForNode().length"
  112. class="inspect"
  113. type="button"
  114. title="展开路径"
  115. @click="onInspect"
  116. >
  117. 🔍
  118. </button>
  119. </div>
  120. </div>
  121. <DemandPathPanel
  122. v-if="isInspecting"
  123. :open="true"
  124. :category-name="inspectCategoryName"
  125. :items="inspectItems"
  126. @close="emit('closeInspect')"
  127. />
  128. <ul v-else-if="hasChildren() && isExpanded()" class="children">
  129. <li
  130. v-for="child in node.children"
  131. :key="child.id"
  132. class="child"
  133. >
  134. <TreeNode
  135. :node="child"
  136. :depth="depth + 1"
  137. :expand-depth="expandDepth"
  138. :expanded-map="expandedMap"
  139. :collapsed-map="collapsedMap"
  140. :demands-by-category="demandsByCategory"
  141. :active-dim="activeDim"
  142. :weight-scale="weightScale"
  143. :inspect-category-id="inspectCategoryId"
  144. :inspect-category-name="inspectCategoryName"
  145. :inspect-items="inspectItems"
  146. @toggle="emit('toggle', $event)"
  147. @inspect="emit('inspect', $event)"
  148. @close-inspect="emit('closeInspect')"
  149. />
  150. </li>
  151. </ul>
  152. </div>
  153. </template>
  154. <style scoped>
  155. .tree-node {
  156. --line-y: 20px;
  157. --line-color: #cbd5e1;
  158. --gap: var(--col-gap, 36px);
  159. display: flex;
  160. flex-direction: row;
  161. align-items: flex-start;
  162. }
  163. .node-card {
  164. display: flex;
  165. align-items: center;
  166. gap: 8px;
  167. width: var(--col-width, 180px);
  168. min-height: 40px;
  169. padding: 8px 10px;
  170. border: 1px solid #d8dee6;
  171. border-radius: 8px;
  172. box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
  173. flex-shrink: 0;
  174. box-sizing: border-box;
  175. transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
  176. }
  177. .node-card.muted {
  178. opacity: 0.72;
  179. }
  180. .node-card.inspecting {
  181. border-color: #3b82f6;
  182. box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
  183. }
  184. .toggle {
  185. width: 22px;
  186. height: 22px;
  187. border: 1px solid rgba(15, 23, 42, 0.18);
  188. border-radius: 4px;
  189. background: rgba(255, 255, 255, 0.65);
  190. color: #334155;
  191. font-size: 14px;
  192. line-height: 1;
  193. cursor: pointer;
  194. flex-shrink: 0;
  195. padding: 0;
  196. }
  197. .toggle:hover {
  198. border-color: #64748b;
  199. background: #fff;
  200. }
  201. .toggle-spacer {
  202. width: 22px;
  203. flex-shrink: 0;
  204. }
  205. .node-main {
  206. flex: 1;
  207. min-width: 0;
  208. display: flex;
  209. align-items: flex-start;
  210. gap: 4px;
  211. }
  212. .node-text {
  213. flex: 1;
  214. min-width: 0;
  215. display: flex;
  216. flex-direction: column;
  217. gap: 2px;
  218. }
  219. .node-name {
  220. font-size: 13px;
  221. font-weight: 600;
  222. line-height: 1.3;
  223. word-break: break-word;
  224. }
  225. .node-weight {
  226. font-size: 11px;
  227. font-weight: 600;
  228. font-variant-numeric: tabular-nums;
  229. opacity: 0.85;
  230. letter-spacing: 0.01em;
  231. }
  232. .inspect {
  233. flex-shrink: 0;
  234. width: 24px;
  235. height: 24px;
  236. margin-top: -1px;
  237. border: none;
  238. border-radius: 4px;
  239. background: transparent;
  240. font-size: 13px;
  241. line-height: 1;
  242. cursor: pointer;
  243. padding: 0;
  244. }
  245. .inspect:hover {
  246. background: rgba(15, 23, 42, 0.08);
  247. }
  248. .children {
  249. --half-gap: calc(var(--gap) / 2);
  250. --sibling-gap: 10px;
  251. display: flex;
  252. flex-direction: column;
  253. list-style: none;
  254. margin: 0;
  255. padding: 0 0 0 var(--gap);
  256. position: relative;
  257. }
  258. .children::before {
  259. content: '';
  260. position: absolute;
  261. left: 0;
  262. top: var(--line-y);
  263. width: var(--half-gap);
  264. border-top: 2px solid var(--line-color);
  265. }
  266. .child {
  267. position: relative;
  268. }
  269. .child:not(:last-child) {
  270. padding-bottom: var(--sibling-gap);
  271. }
  272. .child::before {
  273. content: '';
  274. position: absolute;
  275. top: var(--line-y);
  276. left: 0;
  277. width: var(--half-gap);
  278. margin-left: calc(-1 * var(--half-gap));
  279. border-top: 2px solid var(--line-color);
  280. }
  281. .child::after {
  282. content: '';
  283. position: absolute;
  284. left: 0;
  285. margin-left: calc(-1 * var(--half-gap));
  286. border-left: 2px solid var(--line-color);
  287. }
  288. .child:not(:first-child):not(:last-child)::after {
  289. top: 0;
  290. bottom: 0;
  291. }
  292. .child:first-child:not(:last-child)::after {
  293. top: var(--line-y);
  294. bottom: 0;
  295. }
  296. .child:last-child:not(:first-child)::after {
  297. top: 0;
  298. height: var(--line-y);
  299. }
  300. .child:only-child::after {
  301. display: none;
  302. }
  303. </style>