| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- <script setup lang="ts">
- import { computed } from 'vue'
- import type { CategoryNode, WeightDimKey } from '../types/category'
- import {
- formatAvgScore,
- heatColor,
- heatTextColor,
- weightHeatT,
- } from '../types/category'
- import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
- const props = defineProps<{
- node: CategoryNode
- depth: number
- expandDepth: number
- expandedMap: Record<number, true>
- collapsedMap: Record<number, true>
- demandsByCategory: DemandsByCategory
- activeDim: WeightDimKey | null
- weightScale: number[]
- }>()
- const emit = defineEmits<{
- toggle: [id: number]
- inspect: [payload: { categoryId: number; categoryName: string; items: DemandBelongItem[] }]
- }>()
- const hasChildren = () => props.node.children.length > 0
- function isExpanded(): boolean {
- if (!hasChildren()) return false
- if (props.collapsedMap[props.node.id]) return false
- if (props.expandedMap[props.node.id]) return true
- return props.depth < props.expandDepth
- }
- function demandsForNode(): DemandBelongItem[] {
- return props.demandsByCategory[props.node.id] ?? []
- }
- const weight = computed(() =>
- props.activeDim ? (props.node.weights?.[props.activeDim] ?? 0) : null,
- )
- const count = computed(() =>
- props.activeDim ? (props.node.counts?.[props.activeDim] ?? 0) : 0,
- )
- const heatT = computed(() => {
- if (!props.activeDim || count.value <= 0 || weight.value == null) return null
- return weightHeatT(weight.value, props.weightScale)
- })
- const cardStyle = computed(() => {
- const t = heatT.value
- const bg = heatColor(t)
- const color = heatTextColor(t)
- return {
- background: bg,
- color,
- borderColor: t == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)',
- }
- })
- function onInspect(e: MouseEvent) {
- e.stopPropagation()
- const items = demandsForNode()
- emit('inspect', {
- categoryId: props.node.id,
- categoryName: props.node.name || '(未命名)',
- items,
- })
- }
- </script>
- <template>
- <div class="tree-node">
- <div
- class="node-card"
- :class="{ leaf: !hasChildren(), open: isExpanded(), muted: activeDim != null && heatT == null }"
- :style="cardStyle"
- :title="
- !activeDim
- ? (node.name || '(未命名)')
- : count > 0
- ? `avg=${weight} count=${count}`
- : '无数据'
- "
- >
- <button
- v-if="hasChildren()"
- class="toggle"
- type="button"
- :aria-expanded="isExpanded()"
- :title="isExpanded() ? '收起' : '展开'"
- @click="emit('toggle', node.id)"
- >
- {{ isExpanded() ? '−' : '+' }}
- </button>
- <span v-else class="toggle-spacer" />
- <div class="node-main">
- <div class="node-text">
- <span class="node-name">{{ node.name || '(未命名)' }}</span>
- <span v-if="activeDim" class="node-weight">
- {{ formatAvgScore(weight, count) }}
- </span>
- </div>
- <button
- v-if="demandsForNode().length"
- class="inspect"
- type="button"
- title="查看需求词"
- @click="onInspect"
- >
- 🔍
- </button>
- </div>
- </div>
- <ul v-if="hasChildren() && isExpanded()" class="children">
- <li
- v-for="child in node.children"
- :key="child.id"
- class="child"
- >
- <TreeNode
- :node="child"
- :depth="depth + 1"
- :expand-depth="expandDepth"
- :expanded-map="expandedMap"
- :collapsed-map="collapsedMap"
- :demands-by-category="demandsByCategory"
- :active-dim="activeDim"
- :weight-scale="weightScale"
- @toggle="emit('toggle', $event)"
- @inspect="emit('inspect', $event)"
- />
- </li>
- </ul>
- </div>
- </template>
- <style scoped>
- .tree-node {
- --line-y: 20px;
- --line-color: #cbd5e1;
- --gap: var(--col-gap, 36px);
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- }
- .node-card {
- display: flex;
- align-items: center;
- gap: 8px;
- width: var(--col-width, 180px);
- min-height: 40px;
- padding: 8px 10px;
- border: 1px solid #d8dee6;
- border-radius: 8px;
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
- flex-shrink: 0;
- box-sizing: border-box;
- transition: background 0.15s ease, border-color 0.15s ease;
- }
- .node-card.muted {
- opacity: 0.72;
- }
- .toggle {
- width: 22px;
- height: 22px;
- border: 1px solid rgba(15, 23, 42, 0.18);
- border-radius: 4px;
- background: rgba(255, 255, 255, 0.65);
- color: #334155;
- font-size: 14px;
- line-height: 1;
- cursor: pointer;
- flex-shrink: 0;
- padding: 0;
- }
- .toggle:hover {
- border-color: #64748b;
- background: #fff;
- }
- .toggle-spacer {
- width: 22px;
- flex-shrink: 0;
- }
- .node-main {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: flex-start;
- gap: 4px;
- }
- .node-text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .node-name {
- font-size: 13px;
- font-weight: 600;
- line-height: 1.3;
- word-break: break-word;
- }
- .node-weight {
- font-size: 11px;
- font-weight: 600;
- font-variant-numeric: tabular-nums;
- opacity: 0.85;
- letter-spacing: 0.01em;
- }
- .inspect {
- flex-shrink: 0;
- width: 24px;
- height: 24px;
- margin-top: -1px;
- border: none;
- border-radius: 4px;
- background: transparent;
- font-size: 13px;
- line-height: 1;
- cursor: pointer;
- padding: 0;
- }
- .inspect:hover {
- background: rgba(15, 23, 42, 0.08);
- }
- .children {
- --half-gap: calc(var(--gap) / 2);
- --sibling-gap: 10px;
- display: flex;
- flex-direction: column;
- list-style: none;
- margin: 0;
- padding: 0 0 0 var(--gap);
- position: relative;
- }
- .children::before {
- content: '';
- position: absolute;
- left: 0;
- top: var(--line-y);
- width: var(--half-gap);
- border-top: 2px solid var(--line-color);
- }
- .child {
- position: relative;
- }
- .child:not(:last-child) {
- padding-bottom: var(--sibling-gap);
- }
- .child::before {
- content: '';
- position: absolute;
- top: var(--line-y);
- left: 0;
- width: var(--half-gap);
- margin-left: calc(-1 * var(--half-gap));
- border-top: 2px solid var(--line-color);
- }
- .child::after {
- content: '';
- position: absolute;
- left: 0;
- margin-left: calc(-1 * var(--half-gap));
- border-left: 2px solid var(--line-color);
- }
- .child:not(:first-child):not(:last-child)::after {
- top: 0;
- bottom: 0;
- }
- .child:first-child:not(:last-child)::after {
- top: var(--line-y);
- bottom: 0;
- }
- .child:last-child:not(:first-child)::after {
- top: 0;
- height: var(--line-y);
- }
- .child:only-child::after {
- display: none;
- }
- </style>
|