Przeglądaj źródła

修复前端展示问题

xueyiming 1 tydzień temu
rodzic
commit
17143e2ef0

+ 586 - 0
web/src/components/DemandGradeListPanel.vue

@@ -0,0 +1,586 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { GRADE_ORDER, parseStrategies, gradeRank } from '../types/demand'
+
+type GradeFilter = 'all' | (typeof GRADE_ORDER)[number]
+
+export type DemandGradeListCategoryOption = {
+  categoryId: number
+  categoryName: string
+}
+
+export type DemandGradeListCard = {
+  id: number
+  demand_name: string
+  grade: string
+  score: number | null
+  reason: string | null
+  strategies: string | null
+  biz_dt: string
+  categoryOptions: DemandGradeListCategoryOption[]
+}
+
+const props = defineProps<{
+  demandCards: DemandGradeListCard[]
+  selectedDemandId: number | null
+  selectedCategoryId: number | null
+}>()
+
+const emit = defineEmits<{
+  'select-demand': [payload: { demandId: number; defaultCategoryId: number | null }]
+  'select-demand-category': [payload: { demandId: number; categoryId: number }]
+}>()
+
+const pageSizeOptions = [10, 20, 50, 100, 200]
+const pageSize = ref<number>(10)
+const page = ref<number>(1)
+const gradeFilter = ref<GradeFilter>('all')
+
+const gradeTabs = computed(() => [
+  { key: 'all' as GradeFilter, label: '全部', count: props.demandCards.length },
+  ...GRADE_ORDER.map((grade) => ({
+    key: grade as GradeFilter,
+    label: grade,
+    count: gradeCounts.value[grade] ?? 0,
+  })),
+])
+
+const gradeCounts = computed(() => {
+  const counts = Object.fromEntries(GRADE_ORDER.map((g) => [g, 0])) as Record<
+    (typeof GRADE_ORDER)[number],
+    number
+  >
+  for (const card of props.demandCards) {
+    const g = (card.grade || '').toUpperCase()
+    if (g in counts) counts[g as (typeof GRADE_ORDER)[number]] += 1
+  }
+  return counts
+})
+
+const filteredCards = computed(() => {
+  if (gradeFilter.value === 'all') return props.demandCards
+  return props.demandCards.filter(
+    (card) => (card.grade || '').toUpperCase() === gradeFilter.value,
+  )
+})
+
+const sortedCards = computed(() => {
+  const cards = [...filteredCards.value]
+  cards.sort((a, b) => {
+    const gr = gradeRank(a.grade) - gradeRank(b.grade)
+    if (gr !== 0) return gr
+    const sa = a.score ?? -Infinity
+    const sb = b.score ?? -Infinity
+    // Same grade: higher score first.
+    if (sa !== sb) return sb - sa
+    return a.demand_name.localeCompare(b.demand_name)
+  })
+  return cards
+})
+
+const total = computed(() => sortedCards.value.length)
+const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
+
+const pagedCards = computed(() => {
+  const p = Math.max(1, page.value)
+  const start = (p - 1) * pageSize.value
+  return sortedCards.value.slice(start, start + pageSize.value)
+})
+
+watch([total, pageSize, gradeFilter], () => {
+  page.value = 1
+})
+
+function gradeClass(grade: string | null | undefined): string {
+  const g = (grade || '').toUpperCase()
+  return g ? `grade-${g}` : 'grade-none'
+}
+
+function onSelectDemand(card: DemandGradeListCard) {
+  const defaultCategoryId = card.categoryOptions[0]?.categoryId ?? null
+  emit('select-demand', { demandId: card.id, defaultCategoryId })
+}
+
+function onSelectCategory(card: DemandGradeListCard, categoryId: number) {
+  emit('select-demand-category', { demandId: card.id, categoryId })
+}
+
+function isActiveDemand(cardId: number): boolean {
+  return props.selectedDemandId === cardId
+}
+
+function isActiveCategory(cardId: number, categoryId: number): boolean {
+  return props.selectedDemandId === cardId && props.selectedCategoryId === categoryId
+}
+
+const strategiesMax = 3
+function slicedStrategies(card: DemandGradeListCard): string[] {
+  const s = parseStrategies(card.strategies)
+  return s.slice(0, strategiesMax)
+}
+</script>
+
+<template>
+  <aside class="demand-list">
+    <header class="head">
+      <div class="head-left">
+        <h2 class="title">全部需求</h2>
+        <span class="count" :title="`共 ${total} 条`">{{ total }}</span>
+      </div>
+      <div class="head-right" />
+    </header>
+
+    <div class="body">
+      <div class="grade-bar">
+        <div class="grade-tabs" role="tablist" aria-label="等级筛选">
+          <button
+            v-for="tab in gradeTabs"
+            :key="tab.key"
+            type="button"
+            role="tab"
+            class="grade-tab"
+            :class="{ active: gradeFilter === tab.key }"
+            :aria-selected="gradeFilter === tab.key"
+            @click="gradeFilter = tab.key"
+          >
+            {{ tab.label }}
+            <span class="grade-tab-count">{{ tab.count }}</span>
+          </button>
+        </div>
+      </div>
+
+      <div class="subbar">
+        <label class="page-size">
+          每页
+          <select v-model.number="pageSize">
+            <option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }}</option>
+          </select>
+          条
+        </label>
+      </div>
+
+      <div v-if="!pagedCards.length" class="empty">
+        {{ gradeFilter === 'all' ? '暂无需求数据' : `暂无 ${gradeFilter} 级需求` }}
+      </div>
+
+      <div v-else class="cards">
+        <button
+          v-for="card in pagedCards"
+          :key="card.id"
+          type="button"
+          class="demand-card"
+          :class="{ active: isActiveDemand(card.id) }"
+          @click="onSelectDemand(card)"
+        >
+          <div class="demand-card-top">
+            <span class="grade-badge" :class="gradeClass(card.grade)">{{ card.grade || '—' }}</span>
+          </div>
+
+          <div class="demand-name" :title="card.demand_name">{{ card.demand_name }}</div>
+
+          <div v-if="card.reason" class="reason" :title="card.reason">
+            {{ card.reason }}
+          </div>
+
+          <div v-if="slicedStrategies(card).length" class="strategies">
+            <span class="strategies-label">策略</span>
+            <span v-for="s in slicedStrategies(card)" :key="s" class="strategy-tag">
+              {{ s }}
+            </span>
+          </div>
+
+          <div v-if="card.categoryOptions.length" class="category-options">
+            <span class="category-label">所属分类</span>
+            <div class="category-tags">
+              <button
+                v-for="opt in card.categoryOptions"
+                :key="opt.categoryId"
+                type="button"
+                class="category-tag"
+                :class="{ active: isActiveCategory(card.id, opt.categoryId) }"
+                @click.stop="onSelectCategory(card, opt.categoryId)"
+                :title="opt.categoryName"
+              >
+                {{ opt.categoryName }}
+              </button>
+            </div>
+          </div>
+        </button>
+      </div>
+
+      <footer class="pager">
+        <button type="button" class="pager-btn" :disabled="page <= 1" @click="page = page - 1">
+          上一页
+        </button>
+        <span class="pager-info">
+          第 {{ page }} / {{ totalPages }} 页
+        </span>
+        <button
+          type="button"
+          class="pager-btn"
+          :disabled="page >= totalPages"
+          @click="page = page + 1"
+        >
+          下一页
+        </button>
+      </footer>
+    </div>
+  </aside>
+</template>
+
+<style scoped>
+.demand-list {
+  height: 100%;
+  min-height: 0;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-shrink: 0;
+  padding: 10px 14px;
+  border-bottom: 1px solid #e2e8f0;
+  background: #f8fafc;
+}
+
+.head-left {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  min-width: 0;
+}
+
+.title {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 800;
+  color: #0f172a;
+}
+
+.count {
+  font-size: 14px;
+  font-weight: 800;
+  color: #1d4ed8;
+  font-variant-numeric: tabular-nums;
+}
+
+.head-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.grade-bar {
+  flex-shrink: 0;
+  padding: 10px 14px;
+  border-bottom: 1px solid #edf1f5;
+  overflow-x: auto;
+}
+
+.grade-tabs {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  min-width: min-content;
+}
+
+.grade-tab {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 30px;
+  padding: 0 10px;
+  border: 1px solid #e2e8f0;
+  border-radius: 999px;
+  background: #fff;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 600;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.grade-tab:hover {
+  border-color: #93c5fd;
+  background: #f8fafc;
+  color: #0f172a;
+}
+
+.grade-tab.active {
+  border-color: #2563eb;
+  background: #2563eb;
+  color: #fff;
+}
+
+.grade-tab-count {
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 999px;
+  background: rgba(15, 23, 42, 0.08);
+  color: inherit;
+  font-size: 10px;
+  font-weight: 800;
+  line-height: 18px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.grade-tab.active .grade-tab-count {
+  background: rgba(255, 255, 255, 0.22);
+}
+
+.subbar {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 10px 14px;
+  border-bottom: 1px solid #edf1f5;
+}
+
+
+.page-size {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  color: #334155;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+
+.page-size select {
+  height: 26px;
+  padding: 0 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #fff;
+  font-size: 12px;
+}
+
+.empty {
+  padding: 24px 14px;
+  color: #94a3b8;
+  font-size: 13px;
+  text-align: center;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cards {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.demand-card {
+  text-align: left;
+  border: 1px solid #e2e8f0;
+  border-radius: 12px;
+  background: #fff;
+  padding: 12px 12px;
+  cursor: pointer;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.demand-card:hover {
+  border-color: #93c5fd;
+  box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
+}
+
+.demand-card.active {
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
+}
+
+.demand-card-top {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  justify-content: space-between;
+  margin-bottom: 6px;
+}
+
+.grade-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 32px;
+  height: 22px;
+  padding: 0 8px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 900;
+  letter-spacing: 0.02em;
+}
+
+.grade-badge.grade-S {
+  background: #fee2e2;
+  color: #b91c1c;
+}
+.grade-badge.grade-A {
+  background: #ffedd5;
+  color: #c2410c;
+}
+.grade-badge.grade-B {
+  background: #fef9c3;
+  color: #a16207;
+}
+.grade-badge.grade-C {
+  background: #e0f2fe;
+  color: #0369a1;
+}
+.grade-badge.grade-D {
+  background: #f1f5f9;
+  color: #64748b;
+}
+.grade-badge.grade-none {
+  background: #f1f5f9;
+  color: #94a3b8;
+}
+
+.demand-name {
+  font-size: 14px;
+  font-weight: 900;
+  color: #0f172a;
+  line-height: 1.3;
+  margin-bottom: 6px;
+}
+
+.reason {
+  font-size: 12px;
+  color: #475569;
+  line-height: 1.5;
+  word-break: break-word;
+  white-space: pre-wrap;
+  margin-bottom: 6px;
+}
+
+.strategies {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 8px;
+}
+
+.strategies-label {
+  font-size: 11px;
+  color: #64748b;
+  font-weight: 700;
+  margin-right: 2px;
+}
+
+.strategy-tag {
+  display: inline-flex;
+  align-items: center;
+  height: 20px;
+  padding: 0 8px;
+  border-radius: 999px;
+  background: rgba(99, 102, 241, 0.12);
+  color: #4338ca;
+  font-size: 11px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.category-options {
+  margin-top: 2px;
+}
+
+.category-label {
+  display: inline-block;
+  font-size: 11px;
+  font-weight: 900;
+  color: #64748b;
+  margin-bottom: 6px;
+}
+
+.category-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.category-tag {
+  border: 1px solid #cbd5e1;
+  background: #fff;
+  color: #334155;
+  font-size: 11px;
+  font-weight: 800;
+  padding: 5px 8px;
+  border-radius: 999px;
+  cursor: pointer;
+  max-width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.category-tag:hover {
+  border-color: #93c5fd;
+  background: #eff6ff;
+}
+
+.category-tag.active {
+  border-color: #2563eb;
+  background: #dbeafe;
+  color: #1d4ed8;
+}
+
+.pager {
+  flex-shrink: 0;
+  border-top: 1px solid #edf1f5;
+  padding: 10px 14px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.pager-btn {
+  height: 30px;
+  padding: 0 10px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #fff;
+  color: #334155;
+  font-size: 13px;
+  cursor: pointer;
+}
+
+.pager-btn:disabled {
+  cursor: not-allowed;
+  opacity: 0.6;
+}
+
+.pager-info {
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+}
+</style>
+

+ 15 - 0
web/src/components/DemandPathPanel.vue

@@ -8,6 +8,8 @@ const props = defineProps<{
   open: boolean
   categoryName: string
   items: DemandGradeItem[]
+  /** When set, auto-select and load videos for this demand_grade id. */
+  highlightDemandId?: number | null
   /** When set, prepend a "当前节点" column. */
   nodeName?: string | null
   nodePath?: string | null
@@ -107,6 +109,19 @@ watch(
   },
 )
 
+watch(
+  () => props.highlightDemandId,
+  (nextId) => {
+    if (!props.open) return
+    if (!nextId) return
+    const item = props.items.find((i) => i.id === nextId)
+    if (!item) return
+    if (selectedDemandId.value === nextId) return
+    void selectDemand(item)
+  },
+  { immediate: true },
+)
+
 function gradeClass(grade: string | null | undefined): string {
   const g = (grade || '').toUpperCase()
   return g ? `grade-${g}` : 'grade-none'

+ 216 - 47
web/src/components/IcicleHeatTree.vue

@@ -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 {

+ 3 - 2
web/src/types/demand.ts

@@ -56,10 +56,11 @@ export function parseStrategies(raw: string | null | undefined): string[] {
   }
 }
 
-const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D']
+export const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
+export type DemandGradeLevel = (typeof GRADE_ORDER)[number]
 
 export function gradeRank(grade: string | null | undefined): number {
   if (!grade) return GRADE_ORDER.length
-  const idx = GRADE_ORDER.indexOf(grade.toUpperCase())
+  const idx = GRADE_ORDER.indexOf(grade.toUpperCase() as DemandGradeLevel)
   return idx === -1 ? GRADE_ORDER.length : idx
 }