فهرست منبع

前端增加全局树,和数据地图

xueyiming 2 هفته پیش
والد
کامیت
a23a217597
5فایلهای تغییر یافته به همراه153 افزوده شده و 19 حذف شده
  1. 1 3
      web/src/App.vue
  2. 65 3
      web/src/components/DemandPathPanel.vue
  3. 62 5
      web/src/components/IcicleHeatTree.vue
  4. 10 6
      web/src/router.ts
  5. 15 2
      web/src/views/GlobalDemandMapView.vue

+ 1 - 3
web/src/App.vue

@@ -11,9 +11,7 @@ const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent
     <nav class="nav">
       <RouterLink class="brand" to="/">SupplyAgent</RouterLink>
       <div class="links">
-        <RouterLink to="/" exact-active-class="active">全局分类树</RouterLink>
-        <RouterLink to="/demand-map" active-class="active">平台全局需求地图</RouterLink>
-        <RouterLink to="/demand-process" active-class="active">需求归类过程</RouterLink>
+        <RouterLink to="/" exact-active-class="active">平台全局需求地图</RouterLink>
       </div>
       <span class="current">{{ currentTitle }}</span>
     </nav>

+ 65 - 3
web/src/components/DemandPathPanel.vue

@@ -7,6 +7,9 @@ const props = defineProps<{
   open: boolean
   categoryName: string
   items: DemandBelongItem[]
+  /** When set, prepend a "当前节点" column. */
+  nodeName?: string | null
+  nodePath?: string | null
 }>()
 
 const emit = defineEmits<{
@@ -76,6 +79,28 @@ function selectVideo(video: DemandVideoItem) {
 
 <template>
   <div v-if="open" ref="rootRef" class="path-rail" role="region" :aria-label="`${categoryName} 路径展开`">
+    <!-- 当前节点 -->
+    <template v-if="nodeName">
+      <section class="col col-node">
+        <div class="col-head">
+          <h3 class="col-title">当前节点</h3>
+          <button type="button" class="close-btn" title="收起路径" @click="emit('close')">×</button>
+        </div>
+        <div class="card node-card">
+          <span class="card-label">分类节点</span>
+          <span class="card-value">{{ nodeName }}</span>
+          <span v-if="nodePath" class="card-path">{{ nodePath }}</span>
+        </div>
+      </section>
+
+      <div class="arrow-col" aria-hidden="true">
+        <div class="arrow arrow-slate">
+          <span class="arrow-line" />
+          <span class="arrow-head">▶</span>
+        </div>
+      </div>
+    </template>
+
     <!-- 节点 → 需求词 -->
     <div class="arrow-col" aria-hidden="true">
       <div class="arrow arrow-blue">
@@ -87,7 +112,15 @@ function selectVideo(video: DemandVideoItem) {
     <section class="col col-demand">
       <div class="col-head">
         <h3 class="col-title">细节元素 · 需求词</h3>
-        <button type="button" class="close-btn" title="收起路径" @click="emit('close')">×</button>
+        <button
+          v-if="!nodeName"
+          type="button"
+          class="close-btn"
+          title="收起路径"
+          @click="emit('close')"
+        >
+          ×
+        </button>
       </div>
       <div v-if="items.length" class="card-list">
         <button
@@ -145,13 +178,13 @@ function selectVideo(video: DemandVideoItem) {
     </div>
 
     <section class="col col-topic">
-      <h3 class="col-title">语言提取 · final_topic_json</h3>
+      <h3 class="col-title">选题结果</h3>
       <template v-if="selectedVideo">
         <div v-if="topicDisplay" class="topic-card">
           <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
           <pre class="topic-json">{{ topicDisplay }}</pre>
         </div>
-        <div v-else class="empty">暂无 final_topic_json</div>
+        <div v-else class="empty">暂无选题结果</div>
       </template>
       <div v-else class="empty hint">点击视频展开</div>
     </section>
@@ -199,6 +232,10 @@ function selectVideo(video: DemandVideoItem) {
   color: #1d4ed8;
 }
 
+.col-node .col-title {
+  color: #334155;
+}
+
 .col-video .col-title {
   color: #15803d;
 }
@@ -269,6 +306,24 @@ function selectVideo(video: DemandVideoItem) {
   color: #0f172a;
 }
 
+.card-path {
+  margin-top: 4px;
+  font-size: 11px;
+  color: #94a3b8;
+  line-height: 1.35;
+  word-break: break-word;
+}
+
+.node-card {
+  border-color: #cbd5e1;
+  background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
+  cursor: default;
+}
+
+.node-card .card-label {
+  color: #475569;
+}
+
 .demand-card {
   border-color: #93c5fd;
   background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
@@ -332,6 +387,13 @@ function selectVideo(video: DemandVideoItem) {
   color: #3b82f6;
 }
 
+.arrow-slate .arrow-line {
+  background: #94a3b8;
+}
+.arrow-slate .arrow-head {
+  color: #94a3b8;
+}
+
 .arrow-green .arrow-line {
   background: #22c55e;
 }

+ 62 - 5
web/src/components/IcicleHeatTree.vue

@@ -7,6 +7,7 @@ import {
   shallowRef,
   watch,
 } from 'vue'
+import DemandPathPanel from './DemandPathPanel.vue'
 import type { CategoryNode, WeightDimKey } from '../types/category'
 import {
   COLUMN_WIDTH,
@@ -28,6 +29,7 @@ import {
   type IcicleRect,
   type PreparedNode,
 } from '../types/heatTree'
+import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
 /** Min cell height in leftmost two columns so labels stay readable. */
 const MIN_READABLE_HEIGHT = 18
@@ -60,6 +62,7 @@ function minLeafInLeftTwoColumns(roots: PreparedNode[]): number {
 const props = defineProps<{
   nodes: CategoryNode[]
   bizDt?: string | null
+  demandsByCategory?: DemandsByCategory
 }>()
 
 const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -82,6 +85,18 @@ const tooltipX = ref(0)
 const tooltipY = ref(0)
 const tooltipNode = ref<PreparedNode | null>(null)
 
+const inspectOpen = ref(false)
+const inspectNode = ref<PreparedNode | null>(null)
+
+const inspectItems = computed<DemandBelongItem[]>(() => {
+  if (!inspectNode.value) return []
+  return props.demandsByCategory?.[inspectNode.value.id] ?? []
+})
+
+function nodeHasDemands(node: PreparedNode): boolean {
+  return (props.demandsByCategory?.[node.id]?.length ?? 0) > 0
+}
+
 /** Plain arrays — avoid Vue reactivity on thousands of rects. */
 let baseRects: IcicleRect[] = []
 let drawRects: IcicleRect[] = []
@@ -217,6 +232,7 @@ function resetGlobalView() {
   startDepth.value = 0
   focus.value = null
   selectedNode.value = null
+  closeInspect()
   hideTooltip()
   scrollPending = false
   needResetTransform = false
@@ -245,9 +261,22 @@ function selectNode(node: PreparedNode, withFocus = false) {
     viewportRef.value.scrollTop = 0
     viewportRef.value.scrollLeft = 0
   }
+  // Auto-open path panel when this category has demand words (same as 🔍 nodes).
+  const items = props.demandsByCategory?.[node.id] ?? []
+  if (items.length) {
+    inspectNode.value = node
+    inspectOpen.value = true
+  } else {
+    closeInspect()
+  }
   scheduleDraw()
 }
 
+function closeInspect() {
+  inspectOpen.value = false
+  inspectNode.value = null
+}
+
 function onDepthClick(depth: number) {
   if (depth === 0) {
     resetGlobalView()
@@ -255,6 +284,7 @@ function onDepthClick(depth: number) {
   }
   focus.value = null
   selectedNode.value = null
+  closeInspect()
   startDepth.value = depth
   hideTooltip()
   scrollPending = false
@@ -468,10 +498,12 @@ function draw() {
       ctx.font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
       ctx.textBaseline = 'middle'
       const maxW = width - 8
-      let text = item.node.name
+      const hasDemand = nodeHasDemands(item.node)
+      const suffix = hasDemand ? ' 🔍' : ''
+      let text = `${item.node.name}${suffix}`
       if (ctx.measureText(text).width > maxW) {
-        const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)))
-        text = `${item.node.name.slice(0, approx)}…`
+        const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)) - (hasDemand ? 2 : 0))
+        text = `${item.node.name.slice(0, Math.max(1, approx))}…${suffix}`
       }
       ctx.fillText(text, x + 5, y + Math.min(height / 2, fontSize + 4))
     }
@@ -752,8 +784,12 @@ onUnmounted(() => {
           class="map-tooltip"
           :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
         >
-          <strong>{{ tooltipNode.name }}</strong>
-          <span>{{ tooltipNode.path.join(' / ') }}</span>
+            <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>
@@ -778,6 +814,17 @@ onUnmounted(() => {
         </div>
       </div>
     </section>
+
+    <div v-if="inspectOpen && inspectNode" class="path-dock">
+      <DemandPathPanel
+        :open="true"
+        :category-name="inspectNode.name"
+        :node-name="inspectNode.name"
+        :node-path="inspectNode.path.join(' / ')"
+        :items="inspectItems"
+        @close="closeInspect"
+      />
+    </div>
   </div>
 </template>
 
@@ -962,6 +1009,16 @@ onUnmounted(() => {
   border-color: #94a3b8;
 }
 
+.path-dock {
+  flex-shrink: 0;
+  max-height: 340px;
+  overflow: auto;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+  padding: 12px 14px;
+}
+
 .depth-axis {
   display: grid;
   padding: 8px 12px;

+ 10 - 6
web/src/router.ts

@@ -8,21 +8,25 @@ export const router = createRouter({
   routes: [
     {
       path: '/',
-      name: 'category-tree',
-      component: CategoryTreeView,
-      meta: { title: '全局分类树' },
-    },
-    {
-      path: '/demand-map',
       name: 'demand-map',
       component: GlobalDemandMapView,
       meta: { title: '平台全局需求地图' },
     },
+    {
+      path: '/demand-tree',
+      name: 'category-tree',
+      component: CategoryTreeView,
+      meta: { title: '全局分类树' },
+    },
     {
       path: '/demand-process',
       name: 'demand-process',
       component: DemandProcessView,
       meta: { title: '需求归类过程' },
     },
+    {
+      path: '/demand-map',
+      redirect: '/',
+    },
   ],
 })

+ 15 - 2
web/src/views/GlobalDemandMapView.vue

@@ -2,18 +2,26 @@
 import { onMounted, ref } from 'vue'
 import IcicleHeatTree from '../components/IcicleHeatTree.vue'
 import { fetchCategoryTree } from '../api/category'
+import { fetchDemandBelongCategory } from '../api/demand'
 import type { CategoryNode } from '../types/category'
+import type { DemandsByCategory } from '../types/demand'
+import { groupDemandsByCategory } from '../types/demand'
 
 const nodes = ref<CategoryNode[]>([])
 const bizDt = ref<string | null>(null)
+const demandsByCategory = ref<DemandsByCategory>({})
 const loading = ref(true)
 const error = ref<string | null>(null)
 
 onMounted(async () => {
   try {
-    const tree = await fetchCategoryTree()
+    const [tree, demands] = await Promise.all([
+      fetchCategoryTree(),
+      fetchDemandBelongCategory(),
+    ])
     nodes.value = tree.nodes ?? []
     bizDt.value = tree.biz_dt ?? null
+    demandsByCategory.value = groupDemandsByCategory(demands.items ?? [])
   } catch (e) {
     error.value = e instanceof Error ? e.message : String(e)
   } finally {
@@ -26,7 +34,12 @@ onMounted(async () => {
   <div class="view">
     <div v-if="loading" class="state">正在加载分类树…</div>
     <div v-else-if="error" class="state error">{{ error }}</div>
-    <IcicleHeatTree v-else :nodes="nodes" :biz-dt="bizDt" />
+    <IcicleHeatTree
+      v-else
+      :nodes="nodes"
+      :biz-dt="bizDt"
+      :demands-by-category="demandsByCategory"
+    />
   </div>
 </template>