|
|
@@ -8,6 +8,8 @@ import {
|
|
|
watch,
|
|
|
} from 'vue'
|
|
|
import DemandPathPanel from './DemandPathPanel.vue'
|
|
|
+import DemandGradeListPanel from './DemandGradeListPanel.vue'
|
|
|
+import type { DemandGradeListCard } from './DemandGradeListPanel.vue'
|
|
|
import type { CategoryNode, WeightDimKey } from '../types/category'
|
|
|
import {
|
|
|
COLUMN_WIDTH,
|
|
|
@@ -70,7 +72,7 @@ const viewportRef = ref<HTMLElement | null>(null)
|
|
|
const wrapRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const prepared = shallowRef(prepareTree([]))
|
|
|
-const activeTab = ref<HeatTabKey>(FULL_TREE_TAB)
|
|
|
+const activeTab = ref<HeatTabKey>('total_score')
|
|
|
const startDepth = ref(0)
|
|
|
const focus = ref<PreparedNode | null>(null)
|
|
|
const selectedNode = ref<PreparedNode | null>(null)
|
|
|
@@ -93,6 +95,96 @@ const inspectItems = computed<DemandGradeItem[]>(() => {
|
|
|
return props.demandsByCategory?.[inspectNode.value.id] ?? []
|
|
|
})
|
|
|
|
|
|
+const listSelectedDemandId = ref<number | null>(null)
|
|
|
+const listSelectedCategoryId = ref<number | null>(null)
|
|
|
+
|
|
|
+const preparedNodeById = computed(() => {
|
|
|
+ const m = new Map<number, PreparedNode>()
|
|
|
+ for (const n of prepared.value.flat) m.set(n.id, n)
|
|
|
+ return m
|
|
|
+})
|
|
|
+
|
|
|
+const demandCards = computed<DemandGradeListCard[]>(() => {
|
|
|
+ // props.demandsByCategory: category_id -> DemandGradeItem[]
|
|
|
+ const metaById = new Map<
|
|
|
+ number,
|
|
|
+ Omit<DemandGradeListCard, 'categoryOptions'> & { _categoryIds: Set<number> }
|
|
|
+ >()
|
|
|
+
|
|
|
+ for (const [categoryIdRaw, items] of Object.entries(props.demandsByCategory ?? {})) {
|
|
|
+ const categoryId = Number(categoryIdRaw)
|
|
|
+ for (const item of items) {
|
|
|
+ if (!item || item.id == null) continue
|
|
|
+ const entry = metaById.get(item.id)
|
|
|
+ if (!entry) {
|
|
|
+ metaById.set(item.id, {
|
|
|
+ id: item.id,
|
|
|
+ demand_name: item.demand_name,
|
|
|
+ grade: item.grade,
|
|
|
+ score: item.score,
|
|
|
+ reason: item.reason,
|
|
|
+ strategies: item.strategies,
|
|
|
+ biz_dt: item.biz_dt,
|
|
|
+ _categoryIds: new Set<number>(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ metaById.get(item.id)!._categoryIds.add(categoryId)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const result: DemandGradeListCard[] = []
|
|
|
+ for (const entry of metaById.values()) {
|
|
|
+ const categoryIds = Array.from(entry._categoryIds).sort((a, b) => a - b)
|
|
|
+ const categoryOptions = categoryIds.map((cid) => ({
|
|
|
+ categoryId: cid,
|
|
|
+ categoryName: preparedNodeById.value.get(cid)?.name ?? '(未命名)',
|
|
|
+ }))
|
|
|
+ result.push({
|
|
|
+ id: entry.id,
|
|
|
+ demand_name: entry.demand_name,
|
|
|
+ grade: entry.grade,
|
|
|
+ score: entry.score,
|
|
|
+ reason: entry.reason,
|
|
|
+ strategies: entry.strategies,
|
|
|
+ biz_dt: entry.biz_dt,
|
|
|
+ categoryOptions,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return result
|
|
|
+})
|
|
|
+
|
|
|
+function clearDemandListSelection() {
|
|
|
+ listSelectedDemandId.value = null
|
|
|
+ listSelectedCategoryId.value = null
|
|
|
+}
|
|
|
+
|
|
|
+const inspectHighlightDemandId = computed<number | null>(() => {
|
|
|
+ if (!inspectNode.value) return null
|
|
|
+ if (listSelectedCategoryId.value !== inspectNode.value.id) return null
|
|
|
+ return listSelectedDemandId.value
|
|
|
+})
|
|
|
+
|
|
|
+function selectCategoryNodeById(categoryId: number) {
|
|
|
+ const node = preparedNodeById.value.get(categoryId)
|
|
|
+ if (!node) return
|
|
|
+ // When selection originates from demand list, keep list selection state.
|
|
|
+ selectNode(node, true, 'demandList')
|
|
|
+}
|
|
|
+
|
|
|
+function onSelectDemandFromList(payload: { demandId: number; defaultCategoryId: number | null }) {
|
|
|
+ listSelectedDemandId.value = payload.demandId
|
|
|
+ listSelectedCategoryId.value = payload.defaultCategoryId
|
|
|
+ if (payload.defaultCategoryId != null) {
|
|
|
+ selectCategoryNodeById(payload.defaultCategoryId)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onSelectDemandCategoryFromList(payload: { demandId: number; categoryId: number }) {
|
|
|
+ listSelectedDemandId.value = payload.demandId
|
|
|
+ listSelectedCategoryId.value = payload.categoryId
|
|
|
+ selectCategoryNodeById(payload.categoryId)
|
|
|
+}
|
|
|
+
|
|
|
function nodeHasDemands(node: PreparedNode): boolean {
|
|
|
return (props.demandsByCategory?.[node.id]?.length ?? 0) > 0
|
|
|
}
|
|
|
@@ -232,6 +324,7 @@ function resetGlobalView() {
|
|
|
startDepth.value = 0
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ clearDemandListSelection()
|
|
|
closeInspect()
|
|
|
hideTooltip()
|
|
|
scrollPending = false
|
|
|
@@ -245,7 +338,12 @@ function resetGlobalView() {
|
|
|
scheduleDraw()
|
|
|
}
|
|
|
|
|
|
-function selectNode(node: PreparedNode, withFocus = false) {
|
|
|
+function selectNode(
|
|
|
+ node: PreparedNode,
|
|
|
+ withFocus = false,
|
|
|
+ source: 'canvas' | 'demandList' = 'canvas',
|
|
|
+) {
|
|
|
+ if (source === 'canvas') clearDemandListSelection()
|
|
|
selectedNode.value = node
|
|
|
if (withFocus) {
|
|
|
focus.value = node
|
|
|
@@ -284,6 +382,7 @@ function onDepthClick(depth: number) {
|
|
|
}
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ clearDemandListSelection()
|
|
|
closeInspect()
|
|
|
startDepth.value = depth
|
|
|
hideTooltip()
|
|
|
@@ -699,7 +798,8 @@ onUnmounted(() => {
|
|
|
<p class="subtitle">完整分类树 · 横向为层级,纵向为分支规模</p>
|
|
|
</header>
|
|
|
|
|
|
- <section class="map-panel">
|
|
|
+ <div class="content-grid">
|
|
|
+ <section class="map-panel">
|
|
|
<div class="dim-bar">
|
|
|
<div class="tabs" role="tablist" aria-label="热度维度">
|
|
|
<button
|
|
|
@@ -764,56 +864,73 @@ onUnmounted(() => {
|
|
|
<button type="button" class="current" @click="resetGlobalView">全局树</button>
|
|
|
</nav>
|
|
|
|
|
|
- <div ref="viewportRef" class="icicle-viewport">
|
|
|
- <div ref="wrapRef" class="icicle-wrap">
|
|
|
- <canvas
|
|
|
- ref="canvasRef"
|
|
|
- class="icicle-canvas"
|
|
|
- :class="{ dragging: isDragging }"
|
|
|
- aria-label="全局分类树冰柱图"
|
|
|
- @pointerdown="onPointerDown"
|
|
|
- @pointermove="onPointerMove"
|
|
|
- @pointerup="onPointerUp"
|
|
|
- @pointercancel="onPointerUp"
|
|
|
- @pointerleave="onPointerLeave"
|
|
|
- @wheel="onWheel"
|
|
|
- />
|
|
|
+ <div class="icicle-stage">
|
|
|
+ <div ref="viewportRef" class="icicle-viewport">
|
|
|
+ <div ref="wrapRef" class="icicle-wrap">
|
|
|
+ <canvas
|
|
|
+ ref="canvasRef"
|
|
|
+ class="icicle-canvas"
|
|
|
+ :class="{ dragging: isDragging }"
|
|
|
+ aria-label="全局分类树冰柱图"
|
|
|
+ @pointerdown="onPointerDown"
|
|
|
+ @pointermove="onPointerMove"
|
|
|
+ @pointerup="onPointerUp"
|
|
|
+ @pointercancel="onPointerUp"
|
|
|
+ @pointerleave="onPointerLeave"
|
|
|
+ @wheel="onWheel"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div
|
|
|
- v-if="tooltipVisible && tooltipNode"
|
|
|
- class="map-tooltip"
|
|
|
- :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
|
|
- >
|
|
|
+ <div class="icicle-overlay">
|
|
|
+ <div
|
|
|
+ v-if="tooltipVisible && tooltipNode"
|
|
|
+ class="map-tooltip"
|
|
|
+ :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
|
|
+ >
|
|
|
<strong>
|
|
|
{{ tooltipNode.name }}
|
|
|
<template v-if="nodeHasDemands(tooltipNode)"> 🔍</template>
|
|
|
</strong>
|
|
|
<span>{{ tooltipNode.path.join(' / ') }}</span>
|
|
|
<span v-if="nodeHasDemands(tooltipNode)">点击可在下方展开需求路径</span>
|
|
|
- <span v-if="activeDim && activeDimLabel">
|
|
|
- {{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
|
|
|
- </span>
|
|
|
- <span>
|
|
|
- 第 {{ tooltipNode.path.length - 1 }} 级
|
|
|
- · {{ tooltipNode.children.length }} 个子节点
|
|
|
- · {{ tooltipNode.leafCount }} 个叶节点
|
|
|
- </span>
|
|
|
- <span v-if="tooltipNode.hung_word_count != null">
|
|
|
- 挂靠词 {{ tooltipNode.hung_word_count }}
|
|
|
- </span>
|
|
|
- <span v-if="tooltipNode.description">{{ tooltipNode.description }}</span>
|
|
|
- </div>
|
|
|
- <div class="map-guide">
|
|
|
- <strong>阅读方式</strong>
|
|
|
- <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮缩放;点击聚焦分支。</span>
|
|
|
- </div>
|
|
|
- <div class="zoom-controls">
|
|
|
- <button type="button" title="缩小" @click="zoomOut">−</button>
|
|
|
- <button type="button" title="自适应可读高度" @click="fitView">适配</button>
|
|
|
- <button type="button" title="放大" @click="zoomIn">+</button>
|
|
|
+ <span v-if="activeDim && activeDimLabel">
|
|
|
+ {{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 第 {{ tooltipNode.path.length - 1 }} 级
|
|
|
+ · {{ tooltipNode.children.length }} 个子节点
|
|
|
+ · {{ tooltipNode.leafCount }} 个叶节点
|
|
|
+ </span>
|
|
|
+ <span v-if="tooltipNode.hung_word_count != null">
|
|
|
+ 挂靠词 {{ tooltipNode.hung_word_count }}
|
|
|
+ </span>
|
|
|
+ <span v-if="tooltipNode.description">{{ tooltipNode.description }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="map-guide">
|
|
|
+ <strong>阅读方式</strong>
|
|
|
+ <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮缩放;点击聚焦分支。</span>
|
|
|
+ </div>
|
|
|
+ <div class="zoom-controls">
|
|
|
+ <button type="button" title="缩小" @click="zoomOut">−</button>
|
|
|
+ <button type="button" title="自适应可读高度" @click="fitView">适配</button>
|
|
|
+ <button type="button" title="放大" @click="zoomIn">+</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </section>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <aside class="demand-list-aside">
|
|
|
+ <DemandGradeListPanel
|
|
|
+ v-if="demandCards.length"
|
|
|
+ :demand-cards="demandCards"
|
|
|
+ :selected-demand-id="listSelectedDemandId"
|
|
|
+ :selected-category-id="listSelectedCategoryId"
|
|
|
+ @select-demand="onSelectDemandFromList"
|
|
|
+ @select-demand-category="onSelectDemandCategoryFromList"
|
|
|
+ />
|
|
|
+ <div v-else class="demand-empty">暂无需求数据</div>
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
|
|
|
<div v-if="inspectOpen && inspectNode" class="path-dock">
|
|
|
<div class="path-dock-head">
|
|
|
@@ -837,6 +954,7 @@ onUnmounted(() => {
|
|
|
:node-name="inspectNode.name"
|
|
|
:node-path="inspectNode.path.join(' / ')"
|
|
|
:items="inspectItems"
|
|
|
+ :highlight-demand-id="inspectHighlightDemandId"
|
|
|
hide-inline-close
|
|
|
@close="closeInspect"
|
|
|
/>
|
|
|
@@ -891,13 +1009,46 @@ onUnmounted(() => {
|
|
|
display: grid;
|
|
|
grid-template-rows: auto auto auto auto minmax(0, 1fr);
|
|
|
flex: 1;
|
|
|
- min-height: min(240px, 32vh);
|
|
|
+ min-height: 0;
|
|
|
overflow: hidden;
|
|
|
border: 1px solid #e2e8f0;
|
|
|
border-radius: 10px;
|
|
|
background: #fff;
|
|
|
}
|
|
|
|
|
|
+.content-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(520px, 1fr) 360px;
|
|
|
+ gap: 12px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ align-items: stretch;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-list-aside {
|
|
|
+ min-height: 0;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-empty {
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 13px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 980px) {
|
|
|
+ .content-grid {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
.dim-bar {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
@@ -1170,17 +1321,34 @@ onUnmounted(() => {
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
|
|
|
-.icicle-viewport {
|
|
|
+.icicle-stage {
|
|
|
position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
min-height: 0;
|
|
|
margin: 12px;
|
|
|
- overflow: auto;
|
|
|
+ overflow: hidden;
|
|
|
border: 1px solid #cbd5e1;
|
|
|
border-radius: 10px;
|
|
|
background: #f8fafc;
|
|
|
+}
|
|
|
+
|
|
|
+.icicle-viewport {
|
|
|
+ position: relative;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: auto;
|
|
|
+ background: #f8fafc;
|
|
|
scrollbar-gutter: stable;
|
|
|
}
|
|
|
|
|
|
+.icicle-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 5;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
.icicle-wrap {
|
|
|
position: relative;
|
|
|
min-height: 0;
|
|
|
@@ -1260,6 +1428,7 @@ onUnmounted(() => {
|
|
|
z-index: 6;
|
|
|
display: flex;
|
|
|
gap: 4px;
|
|
|
+ pointer-events: auto;
|
|
|
}
|
|
|
|
|
|
.zoom-controls button {
|