Przeglądaj źródła

修改前端页面展示

xueyiming 2 tygodni temu
rodzic
commit
a8994314b0

+ 1 - 0
.gitignore

@@ -14,3 +14,4 @@ logs/
 tests/
 node_modules/
 web/dist/
+examples/

+ 16 - 0
api/app.py

@@ -5,6 +5,8 @@ from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from api.services.category_tree import build_category_tree
+from api.services.demand_belong_category import list_demand_belong_categories
+from api.services.oss_logs import list_demand_belong_oss_logs
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0")
 
@@ -32,3 +34,17 @@ def category_tree() -> dict:
     """Return the full nested global_tree_category tree in one response."""
     nodes = build_category_tree()
     return {"nodes": nodes}
+
+
+@app.get("/api/demand-belong-category")
+def demand_belong_category() -> dict:
+    """Return all active demand_belong_category rows in one response."""
+    items = list_demand_belong_categories()
+    return {"items": items}
+
+
+@app.get("/api/demand-belong-oss-logs")
+def demand_belong_oss_logs() -> dict:
+    """Return demand_belong_category_agent oss_logs ordered by create_time desc."""
+    items = list_demand_belong_oss_logs()
+    return {"items": items}

+ 25 - 0
api/services/demand_belong_category.py

@@ -0,0 +1,25 @@
+"""Load demand_belong_category associations."""
+from __future__ import annotations
+
+from typing import Any
+
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+
+def list_demand_belong_categories() -> list[dict[str, Any]]:
+    """Return all active demand ↔ category rows."""
+    with get_session() as session:
+        repo = DemandBelongCategoryRepository(session)
+        rows = repo.list_active()
+        return [
+            {
+                "id": row.id,
+                "name": row.name,
+                "category_id": row.category_id,
+                "reason": row.reason,
+            }
+            for row in rows
+        ]

+ 28 - 0
api/services/oss_logs.py

@@ -0,0 +1,28 @@
+"""Query oss_logs for agent run visualizations."""
+from __future__ import annotations
+
+from typing import Any
+
+from supply_infra.db.repositories.oss_log_repo import OssLogRepository
+from supply_infra.db.session import get_session
+
+_DEMAND_AGENT = "demand_belong_category_agent"
+
+
+def list_demand_belong_oss_logs() -> list[dict[str, Any]]:
+    """List demand_belong_category_agent oss logs, newest first."""
+    with get_session() as session:
+        repo = OssLogRepository(session)
+        rows = repo.list_by_agent_name(_DEMAND_AGENT)
+        return [
+            {
+                "id": row.id,
+                "log_name": row.log_name,
+                "agent_name": row.agent_name,
+                "oss_path": row.oss_path,
+                "create_time": row.create_time.isoformat(sep=" ", timespec="seconds")
+                if row.create_time
+                else None,
+            }
+            for row in rows
+        ]

+ 9 - 0
supply_infra/db/repositories/demand_belong_category_repo.py

@@ -31,6 +31,15 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
             existing.update(n for n in self.session.scalars(stmt).all() if n)
         return existing
 
+    def list_active(self) -> list[DemandBelongCategory]:
+        """返回所有未删除的需求归属记录,按 category_id、id 排序。"""
+        stmt = (
+            select(DemandBelongCategory)
+            .where(DemandBelongCategory.is_delete == 0)
+            .order_by(DemandBelongCategory.category_id, DemandBelongCategory.id)
+        )
+        return list(self.session.scalars(stmt).all())
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 按 name 唯一索引忽略已存在行。"""
         if not rows:

+ 14 - 0
supply_infra/db/repositories/oss_log_repo.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from sqlalchemy import select
+
 from supply_infra.db.models.oss_log import OssLog
 from supply_infra.db.repositories.base import BaseRepository
 
@@ -21,3 +23,15 @@ class OssLogRepository(BaseRepository[OssLog]):
             is_delete=0,
         )
         return self.add(entity)
+
+    def list_by_agent_name(self, agent_name: str) -> list[OssLog]:
+        """按 agent_name 查未删除记录,create_time 倒序。"""
+        stmt = (
+            select(OssLog)
+            .where(
+                OssLog.agent_name == agent_name,
+                OssLog.is_delete == 0,
+            )
+            .order_by(OssLog.create_time.desc(), OssLog.id.desc())
+        )
+        return list(self.session.scalars(stmt).all())

+ 4 - 15
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -6,7 +6,7 @@
 2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余
 3. 查询当天全部 demand_name,按空格分词写入 set
 4. 过滤 demand_belong_category 中已存在的词
-5. 剩余词按 100 词一批调用 demand_belong_category_agent(测试阶段仅 1 批)
+5. 剩余词按 100 词一批调用 demand_belong_category_agent
 """
 from __future__ import annotations
 
@@ -25,8 +25,6 @@ from supply_infra.odps.client import get_odps_client
 logger = logging.getLogger(__name__)
 
 _WORD_BATCH_SIZE = 100
-# 测试阶段只跑 1 批;上线后改为 None 表示跑完全部
-_MAX_CLASSIFY_BATCHES = 1
 
 RowKey = tuple[str, str]
 
@@ -76,7 +74,7 @@ def _chunked(items: list[str], size: int) -> list[list[str]]:
     return [items[i : i + size] for i in range(0, len(items), size)]
 
 
-def _classify_words(biz_dt: str, *, max_batches: int | None = _MAX_CLASSIFY_BATCHES) -> dict:
+def _classify_words(biz_dt: str) -> dict:
     """查询 demand_name → 空格分词入 set → 过滤已有词 → 100 词一批调用 agent。"""
     with get_session() as session:
         demand_names = MultiDemandPoolDiRepository(session).list_demand_names_by_biz_dt(biz_dt)
@@ -86,35 +84,26 @@ def _classify_words(biz_dt: str, *, max_batches: int | None = _MAX_CLASSIFY_BATC
 
     word_list = list(pending)
     batches = _chunked(word_list, _WORD_BATCH_SIZE)
-    if max_batches is not None:
-        batches = batches[:max_batches]
 
     logger.info(
-        "Classify prepare: demand_names=%d words=%d existing=%d pending=%d batches=%d (max=%s)",
+        "Classify prepare: demand_names=%d words=%d existing=%d pending=%d batches=%d",
         len(demand_names),
         len(word_set),
         len(existing),
         len(pending),
         len(batches),
-        max_batches,
     )
 
-    classified_batches = 0
     for idx, batch in enumerate(batches, start=1):
-        batch = list(dict.fromkeys(batch))
         logger.info("Classifying batch %d/%d (%d words)", idx, len(batches), len(batch))
         classify_demand_words(batch)
-        classified_batches += 1
 
     return {
         "demand_names": len(demand_names),
         "words": len(word_set),
         "existing_filtered": len(existing),
         "pending": len(pending),
-        "batches_total": (len(word_list) + _WORD_BATCH_SIZE - 1) // _WORD_BATCH_SIZE
-        if word_list
-        else 0,
-        "batches_ran": classified_batches,
+        "batches": len(batches),
     }
 
 

+ 23 - 1
web/package-lock.json

@@ -8,7 +8,8 @@
       "name": "web",
       "version": "0.0.0",
       "dependencies": {
-        "vue": "^3.5.39"
+        "vue": "^3.5.39",
+        "vue-router": "^4.6.4"
       },
       "devDependencies": {
         "@types/node": "^24.13.2",
@@ -515,6 +516,12 @@
         "@vue/shared": "3.5.39"
       }
     },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
     "node_modules/@vue/language-core": {
       "version": "3.3.7",
       "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.7.tgz",
@@ -1218,6 +1225,21 @@
         }
       }
     },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
     "node_modules/vue-tsc": {
       "version": "3.3.7",
       "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.7.tgz",

+ 2 - 1
web/package.json

@@ -9,7 +9,8 @@
     "preview": "vite preview"
   },
   "dependencies": {
-    "vue": "^3.5.39"
+    "vue": "^3.5.39",
+    "vue-router": "^4.6.4"
   },
   "devDependencies": {
     "@types/node": "^24.13.2",

+ 85 - 34
web/src/App.vue

@@ -1,49 +1,100 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
-import CategoryTree from './components/CategoryTree.vue'
-import { fetchCategoryTree } from './api/category'
-import type { CategoryNode } from './types/category'
-
-const nodes = ref<CategoryNode[]>([])
-const loading = ref(true)
-const error = ref<string | null>(null)
-
-onMounted(async () => {
-  try {
-    const data = await fetchCategoryTree()
-    nodes.value = data.nodes ?? []
-  } catch (e) {
-    error.value = e instanceof Error ? e.message : String(e)
-  } finally {
-    loading.value = false
-  }
-})
+import { RouterLink, RouterView, useRoute } from 'vue-router'
+import { computed } from 'vue'
+
+const route = useRoute()
+const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent')
 </script>
 
 <template>
-  <div class="page">
-    <div v-if="loading" class="state">正在加载分类树…</div>
-    <div v-else-if="error" class="state error">{{ error }}</div>
-    <CategoryTree v-else :nodes="nodes" />
+  <div class="app-shell">
+    <nav class="nav">
+      <RouterLink class="brand" to="/">SupplyAgent</RouterLink>
+      <div class="links">
+        <RouterLink to="/" exact-active-class="active">全局分类树</RouterLink>
+        <RouterLink to="/demand-process" active-class="active">需求归类过程</RouterLink>
+      </div>
+      <span class="current">{{ currentTitle }}</span>
+    </nav>
+    <main class="main">
+      <RouterView />
+    </main>
   </div>
 </template>
 
 <style scoped>
-.page {
-  max-width: 100%;
-  min-height: 100vh;
-  padding: 28px 32px;
-  box-sizing: border-box;
+.app-shell {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  overflow: hidden;
+}
+
+.nav {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 0 28px;
+  height: 48px;
+  flex-shrink: 0;
+  border-bottom: 1px solid #e2e8f0;
+  background: rgba(255, 255, 255, 0.82);
+  backdrop-filter: blur(8px);
 }
 
-.state {
-  padding: 64px 24px;
-  text-align: center;
-  color: #64748b;
+.brand {
+  font-weight: 700;
   font-size: 15px;
+  color: #0f172a;
+  text-decoration: none;
+  letter-spacing: -0.02em;
+}
+
+.links {
+  display: flex;
+  gap: 6px;
+}
+
+.links a {
+  display: inline-flex;
+  align-items: center;
+  height: 32px;
+  padding: 0 14px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  color: #334155;
+  text-decoration: none;
+  box-shadow: 0 1px 1px rgba(15, 23, 42, 0.04);
+  transition: background 0.15s, border-color 0.15s, color 0.15s;
 }
 
-.state.error {
-  color: #b91c1c;
+.links a:hover {
+  background: #f8fafc;
+  border-color: #94a3b8;
+  color: #0f172a;
+}
+
+.links a.active {
+  background: #0f172a;
+  border-color: #0f172a;
+  color: #fff;
+  box-shadow: none;
+}
+
+.current {
+  margin-left: auto;
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.main {
+  flex: 1;
+  min-height: 0;
+  padding: 20px 28px 24px;
+  box-sizing: border-box;
+  overflow: hidden;
 }
 </style>

+ 9 - 0
web/src/api/demand.ts

@@ -0,0 +1,9 @@
+import type { DemandBelongResponse } from '../types/demand'
+
+export async function fetchDemandBelongCategory(): Promise<DemandBelongResponse> {
+  const res = await fetch('/api/demand-belong-category')
+  if (!res.ok) {
+    throw new Error(`加载需求归属失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}

+ 9 - 0
web/src/api/ossLog.ts

@@ -0,0 +1,9 @@
+import type { OssLogListResponse } from '../types/ossLog'
+
+export async function fetchDemandBelongOssLogs(): Promise<OssLogListResponse> {
+  const res = await fetch('/api/demand-belong-oss-logs')
+  if (!res.ok) {
+    throw new Error(`加载归类过程日志失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}

+ 148 - 26
web/src/components/CategoryTree.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import TreeNode from './TreeNode.vue'
+import DemandDrawer from './DemandDrawer.vue'
 import type { CategoryNode } from '../types/category'
 import { maxTreeDepth } from '../types/category'
+import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
 const props = defineProps<{
   nodes: CategoryNode[]
+  demandsByCategory: DemandsByCategory
 }>()
 
 const DEFAULT_DEPTH = 3
@@ -16,6 +19,17 @@ const expandDepth = ref(DEFAULT_DEPTH)
 const expandedMap = ref<Record<number, true>>({})
 const collapsedMap = ref<Record<number, true>>({})
 
+const drawerOpen = ref(false)
+const drawerCategoryName = ref('')
+const drawerItems = ref<DemandBelongItem[]>([])
+
+const treePanelRef = ref<HTMLElement | null>(null)
+const isPanning = ref(false)
+let panStartX = 0
+let panStartY = 0
+let panScrollLeft = 0
+let panScrollTop = 0
+
 const treeMaxDepth = computed(() => maxTreeDepth(props.nodes))
 
 const depthOptions = computed(() => {
@@ -79,6 +93,51 @@ function toggleNode(id: number) {
 function expandAll() {
   expandDepth.value = treeMaxDepth.value || 1
 }
+
+function onInspect(payload: {
+  categoryId: number
+  categoryName: string
+  items: DemandBelongItem[]
+}) {
+  drawerCategoryName.value = payload.categoryName
+  drawerItems.value = payload.items
+  drawerOpen.value = true
+}
+
+function closeDrawer() {
+  drawerOpen.value = false
+}
+
+function onPanStart(e: MouseEvent) {
+  const el = treePanelRef.value
+  if (!el || e.button !== 0) return
+  // 点在按钮上时不启动拖拽,避免和展开/查看冲突
+  const target = e.target as HTMLElement | null
+  if (target?.closest('button, select, a, input')) return
+
+  isPanning.value = true
+  panStartX = e.clientX
+  panStartY = e.clientY
+  panScrollLeft = el.scrollLeft
+  panScrollTop = el.scrollTop
+  el.classList.add('is-panning')
+  window.addEventListener('mousemove', onPanMove)
+  window.addEventListener('mouseup', onPanEnd)
+}
+
+function onPanMove(e: MouseEvent) {
+  const el = treePanelRef.value
+  if (!el || !isPanning.value) return
+  el.scrollLeft = panScrollLeft - (e.clientX - panStartX)
+  el.scrollTop = panScrollTop - (e.clientY - panStartY)
+}
+
+function onPanEnd() {
+  isPanning.value = false
+  treePanelRef.value?.classList.remove('is-panning')
+  window.removeEventListener('mousemove', onPanMove)
+  window.removeEventListener('mouseup', onPanEnd)
+}
 </script>
 
 <template>
@@ -97,34 +156,52 @@ function expandAll() {
           <span>层</span>
         </label>
         <button type="button" class="btn" @click="expandAll">全部展开</button>
-        <span class="hint">共 {{ treeMaxDepth }} 层 · 点击节点旁 +/− 手动展开/收起</span>
+        <span class="hint">
+          共 {{ treeMaxDepth }} 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚
+        </span>
       </div>
     </header>
 
     <div v-if="!nodes.length" class="empty">暂无分类数据</div>
-    <div v-else class="tree-panel">
-      <div class="level-headers">
-        <div
-          v-for="n in headerLevels"
-          :key="n"
-          class="level-header"
-        >
-          第 {{ n }} 层
+    <div
+      v-else
+      ref="treePanelRef"
+      class="tree-panel"
+      @mousedown="onPanStart"
+    >
+      <div class="tree-scroll-inner">
+        <div class="level-headers">
+          <div
+            v-for="n in headerLevels"
+            :key="n"
+            class="level-header"
+          >
+            第 {{ n }} 层
+          </div>
+        </div>
+        <div class="forest">
+          <TreeNode
+            v-for="node in nodes"
+            :key="node.id"
+            :node="node"
+            :depth="1"
+            :expand-depth="expandDepth"
+            :expanded-map="expandedMap"
+            :collapsed-map="collapsedMap"
+            :demands-by-category="demandsByCategory"
+            @toggle="toggleNode"
+            @inspect="onInspect"
+          />
         </div>
-      </div>
-      <div class="forest">
-        <TreeNode
-          v-for="node in nodes"
-          :key="node.id"
-          :node="node"
-          :depth="1"
-          :expand-depth="expandDepth"
-          :expanded-map="expandedMap"
-          :collapsed-map="collapsedMap"
-          @toggle="toggleNode"
-        />
       </div>
     </div>
+
+    <DemandDrawer
+      :open="drawerOpen"
+      :category-name="drawerCategoryName"
+      :items="drawerItems"
+      @close="closeDrawer"
+    />
   </div>
 </template>
 
@@ -132,8 +209,10 @@ function expandAll() {
 .category-tree {
   display: flex;
   flex-direction: column;
-  gap: 20px;
-  min-height: 100%;
+  gap: 16px;
+  height: 100%;
+  min-height: 0;
+  max-width: 100%;
 }
 
 .toolbar {
@@ -144,6 +223,7 @@ function expandAll() {
   gap: 12px 24px;
   padding-bottom: 16px;
   border-bottom: 1px solid #e2e8f0;
+  flex-shrink: 0;
 }
 
 .toolbar h1 {
@@ -200,8 +280,46 @@ function expandAll() {
 }
 
 .tree-panel {
-  overflow: auto;
-  padding: 0 4px 32px;
+  flex: 1;
+  min-height: 0;
+  min-width: 0;
+  width: 100%;
+  overflow: scroll;
+  overscroll-behavior: contain;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.55);
+  cursor: grab;
+  scrollbar-gutter: stable;
+}
+
+.tree-panel.is-panning {
+  cursor: grabbing;
+  user-select: none;
+}
+
+.tree-panel::-webkit-scrollbar {
+  width: 12px;
+  height: 12px;
+}
+
+.tree-panel::-webkit-scrollbar-thumb {
+  background: #94a3b8;
+  border-radius: 8px;
+  border: 2px solid transparent;
+  background-clip: content-box;
+}
+
+.tree-panel::-webkit-scrollbar-track {
+  background: #e2e8f0;
+  border-radius: 8px;
+}
+
+.tree-scroll-inner {
+  display: inline-block;
+  min-width: 100%;
+  padding: 8px 12px 24px;
+  vertical-align: top;
 }
 
 .level-headers {
@@ -211,11 +329,13 @@ function expandAll() {
   position: sticky;
   top: 0;
   z-index: 2;
-  background: rgba(241, 245, 249, 0.92);
+  background: rgba(248, 250, 252, 0.96);
   backdrop-filter: blur(6px);
   border-bottom: 1px solid #e2e8f0;
   margin-bottom: 12px;
   padding: 8px 0;
+  width: max-content;
+  min-width: 100%;
 }
 
 .level-header {
@@ -238,6 +358,8 @@ function expandAll() {
   flex-direction: column;
   align-items: flex-start;
   gap: 16px;
+  width: max-content;
+  min-width: 100%;
 }
 
 .empty {

+ 158 - 0
web/src/components/DemandDrawer.vue

@@ -0,0 +1,158 @@
+<script setup lang="ts">
+import type { DemandBelongItem } from '../types/demand'
+
+defineProps<{
+  open: boolean
+  categoryName: string
+  items: DemandBelongItem[]
+}>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+</script>
+
+<template>
+  <Teleport to="body">
+    <div v-if="open" class="overlay" @click.self="emit('close')">
+      <aside class="drawer" role="dialog" aria-label="需求词列表">
+        <header class="drawer-header">
+          <div>
+            <h2>{{ categoryName || '分类节点' }}</h2>
+            <p class="sub">共 {{ items.length }} 条需求词</p>
+          </div>
+          <button type="button" class="close" title="关闭" @click="emit('close')">×</button>
+        </header>
+
+        <div class="drawer-body">
+          <table v-if="items.length">
+            <thead>
+              <tr>
+                <th class="col-name">需求词</th>
+                <th class="col-reason">原因</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="item in items" :key="item.id">
+                <td>{{ item.name || '—' }}</td>
+                <td>{{ item.reason || '—' }}</td>
+              </tr>
+            </tbody>
+          </table>
+          <div v-else class="empty">该节点暂无需求词</div>
+        </div>
+      </aside>
+    </div>
+  </Teleport>
+</template>
+
+<style scoped>
+.overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  background: rgba(15, 23, 42, 0.28);
+  display: flex;
+  justify-content: flex-end;
+}
+
+.drawer {
+  width: min(480px, 100vw);
+  height: 100%;
+  background: #fff;
+  box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
+  display: flex;
+  flex-direction: column;
+}
+
+.drawer-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 20px 20px 16px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.drawer-header h2 {
+  margin: 0;
+  font-size: 18px;
+  color: #0f172a;
+  word-break: break-word;
+}
+
+.sub {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.close {
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #64748b;
+  font-size: 22px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+
+.close:hover {
+  background: #f1f5f9;
+  color: #0f172a;
+}
+
+.drawer-body {
+  flex: 1;
+  overflow: auto;
+  padding: 0 0 24px;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+
+th,
+td {
+  padding: 10px 16px;
+  text-align: left;
+  vertical-align: top;
+  border-bottom: 1px solid #eef2f7;
+  color: #0f172a;
+  word-break: break-word;
+}
+
+th {
+  position: sticky;
+  top: 0;
+  background: #f8fafc;
+  font-size: 12px;
+  font-weight: 600;
+  color: #64748b;
+  letter-spacing: 0.02em;
+}
+
+.col-name {
+  width: 36%;
+}
+
+.col-reason {
+  width: 64%;
+}
+
+tbody tr:hover td {
+  background: #f8fafc;
+}
+
+.empty {
+  padding: 48px 20px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 14px;
+}
+</style>

+ 57 - 11
web/src/components/TreeNode.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import type { CategoryNode } from '../types/category'
+import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
 const props = defineProps<{
   node: CategoryNode
@@ -7,10 +8,12 @@ const props = defineProps<{
   expandDepth: number
   expandedMap: Record<number, true>
   collapsedMap: Record<number, true>
+  demandsByCategory: DemandsByCategory
 }>()
 
 const emit = defineEmits<{
   toggle: [id: number]
+  inspect: [payload: { categoryId: number; categoryName: string; items: DemandBelongItem[] }]
 }>()
 
 const hasChildren = () => props.node.children.length > 0
@@ -21,6 +24,20 @@ function isExpanded(): boolean {
   if (props.expandedMap[props.node.id]) return true
   return props.depth < props.expandDepth
 }
+
+function demandsForNode(): DemandBelongItem[] {
+  return props.demandsByCategory[props.node.id] ?? []
+}
+
+function onInspect(e: MouseEvent) {
+  e.stopPropagation()
+  const items = demandsForNode()
+  emit('inspect', {
+    categoryId: props.node.id,
+    categoryName: props.node.name || '(未命名)',
+    items,
+  })
+}
 </script>
 
 <template>
@@ -37,7 +54,18 @@ function isExpanded(): boolean {
         {{ isExpanded() ? '−' : '+' }}
       </button>
       <span v-else class="toggle-spacer" />
-      <div class="node-name">{{ node.name || '(未命名)' }}</div>
+      <div class="node-main">
+        <span class="node-name">{{ node.name || '(未命名)' }}</span>
+        <button
+          v-if="demandsForNode().length"
+          class="inspect"
+          type="button"
+          title="查看需求词"
+          @click="onInspect"
+        >
+          🔍
+        </button>
+      </div>
     </div>
 
     <ul v-if="hasChildren() && isExpanded()" class="children">
@@ -52,7 +80,9 @@ function isExpanded(): boolean {
           :expand-depth="expandDepth"
           :expanded-map="expandedMap"
           :collapsed-map="collapsedMap"
+          :demands-by-category="demandsByCategory"
           @toggle="emit('toggle', $event)"
+          @inspect="emit('inspect', $event)"
         />
       </li>
     </ul>
@@ -60,7 +90,6 @@ function isExpanded(): boolean {
 </template>
 
 <style scoped>
-/* 节点卡片垂直中线,用于对齐连线 */
 .tree-node {
   --line-y: 20px;
   --line-color: #cbd5e1;
@@ -120,6 +149,14 @@ function isExpanded(): boolean {
   flex-shrink: 0;
 }
 
+.node-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: flex-start;
+  gap: 4px;
+}
+
 .node-name {
   flex: 1;
   min-width: 0;
@@ -130,20 +167,36 @@ function isExpanded(): boolean {
   word-break: break-word;
 }
 
+.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: #e2e8f0;
+}
+
 .children {
   --half-gap: calc(var(--gap) / 2);
   --sibling-gap: 10px;
 
   display: flex;
   flex-direction: column;
-  /* 不用 flex gap:空隙不在子项盒模型内,竖线会断开 */
   list-style: none;
   margin: 0;
   padding: 0 0 0 var(--gap);
   position: relative;
 }
 
-/* 父节点 → 竖脊:左半段水平线 */
 .children::before {
   content: '';
   position: absolute;
@@ -157,12 +210,10 @@ function isExpanded(): boolean {
   position: relative;
 }
 
-/* 间距放进 padding,让竖脊 ::after 能连续穿过 */
 .child:not(:last-child) {
   padding-bottom: var(--sibling-gap);
 }
 
-/* 竖脊 → 子节点:右半段水平线 */
 .child::before {
   content: '';
   position: absolute;
@@ -173,7 +224,6 @@ function isExpanded(): boolean {
   border-top: 2px solid var(--line-color);
 }
 
-/* 兄弟之间的竖脊(位于 gap 中点) */
 .child::after {
   content: '';
   position: absolute;
@@ -182,25 +232,21 @@ function isExpanded(): boolean {
   border-left: 2px solid var(--line-color);
 }
 
-/* 中间兄弟:通栏竖线(含底部 padding) */
 .child:not(:first-child):not(:last-child)::after {
   top: 0;
   bottom: 0;
 }
 
-/* 第一个兄弟:从中线画到自身底部(含 padding) */
 .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;
 }

+ 2 - 1
web/src/main.ts

@@ -1,5 +1,6 @@
 import { createApp } from 'vue'
 import './style.css'
 import App from './App.vue'
+import { router } from './router'
 
-createApp(App).mount('#app')
+createApp(App).use(router).mount('#app')

+ 21 - 0
web/src/router.ts

@@ -0,0 +1,21 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import CategoryTreeView from './views/CategoryTreeView.vue'
+import DemandProcessView from './views/DemandProcessView.vue'
+
+export const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      name: 'category-tree',
+      component: CategoryTreeView,
+      meta: { title: '全局分类树' },
+    },
+    {
+      path: '/demand-process',
+      name: 'demand-process',
+      component: DemandProcessView,
+      meta: { title: '需求归类过程' },
+    },
+  ],
+})

+ 2 - 1
web/src/style.css

@@ -19,7 +19,8 @@ body,
 #app {
   margin: 0;
   min-width: 320px;
-  min-height: 100%;
+  height: 100%;
+  overflow: hidden;
 }
 
 body {

+ 23 - 0
web/src/types/demand.ts

@@ -0,0 +1,23 @@
+export interface DemandBelongItem {
+  id: number
+  name: string | null
+  category_id: number
+  reason: string | null
+}
+
+export interface DemandBelongResponse {
+  items: DemandBelongItem[]
+}
+
+/** category_id → demands belonging to that category */
+export type DemandsByCategory = Record<number, DemandBelongItem[]>
+
+export function groupDemandsByCategory(items: DemandBelongItem[]): DemandsByCategory {
+  const map: DemandsByCategory = {}
+  for (const item of items) {
+    const key = item.category_id
+    if (!map[key]) map[key] = []
+    map[key].push(item)
+  }
+  return map
+}

+ 11 - 0
web/src/types/ossLog.ts

@@ -0,0 +1,11 @@
+export interface OssLogItem {
+  id: number
+  log_name: string | null
+  agent_name: string | null
+  oss_path: string | null
+  create_time: string | null
+}
+
+export interface OssLogListResponse {
+  items: OssLogItem[]
+}

+ 59 - 0
web/src/views/CategoryTreeView.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import CategoryTree from '../components/CategoryTree.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 demandsByCategory = ref<DemandsByCategory>({})
+const loading = ref(true)
+const error = ref<string | null>(null)
+
+onMounted(async () => {
+  try {
+    const [tree, demands] = await Promise.all([
+      fetchCategoryTree(),
+      fetchDemandBelongCategory(),
+    ])
+    nodes.value = tree.nodes ?? []
+    demandsByCategory.value = groupDemandsByCategory(demands.items ?? [])
+  } catch (e) {
+    error.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<template>
+  <div class="view">
+    <div v-if="loading" class="state">正在加载分类树…</div>
+    <div v-else-if="error" class="state error">{{ error }}</div>
+    <CategoryTree
+      v-else
+      :nodes="nodes"
+      :demands-by-category="demandsByCategory"
+    />
+  </div>
+</template>
+
+<style scoped>
+.view {
+  height: 100%;
+  min-height: 0;
+}
+
+.state {
+  padding: 64px 24px;
+  text-align: center;
+  color: #64748b;
+  font-size: 15px;
+}
+
+.state.error {
+  color: #b91c1c;
+}
+</style>

+ 151 - 0
web/src/views/DemandProcessView.vue

@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { fetchDemandBelongOssLogs } from '../api/ossLog'
+import type { OssLogItem } from '../types/ossLog'
+
+const items = ref<OssLogItem[]>([])
+const loading = ref(true)
+const error = ref<string | null>(null)
+
+onMounted(async () => {
+  try {
+    const data = await fetchDemandBelongOssLogs()
+    items.value = data.items ?? []
+  } catch (e) {
+    error.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    loading.value = false
+  }
+})
+
+function openHtml(item: OssLogItem) {
+  const url = item.oss_path?.trim()
+  if (!url) return
+  window.open(url, '_blank', 'noopener,noreferrer')
+}
+</script>
+
+<template>
+  <div class="process-page">
+    <header class="header">
+      <h1>需求归类过程</h1>
+      <p class="sub">demand_belong_category_agent · 按创建时间倒序 · 点击行打开可视化 HTML</p>
+    </header>
+
+    <div v-if="loading" class="state">正在加载…</div>
+    <div v-else-if="error" class="state error">{{ error }}</div>
+    <div v-else-if="!items.length" class="state">暂无日志记录</div>
+    <div v-else class="table-wrap">
+      <table>
+        <thead>
+          <tr>
+            <th class="col-time">创建时间</th>
+            <th class="col-name">日志名称</th>
+            <th class="col-path">oss_path</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            v-for="item in items"
+            :key="item.id"
+            class="row"
+            @click="openHtml(item)"
+          >
+            <td>{{ item.create_time || '—' }}</td>
+            <td>{{ item.log_name || '—' }}</td>
+            <td class="path">{{ item.oss_path || '—' }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.process-page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  gap: 16px;
+}
+
+.header h1 {
+  margin: 0;
+  font-size: 22px;
+  font-weight: 700;
+  color: #0f172a;
+}
+
+.sub {
+  margin: 6px 0 0;
+  font-size: 13px;
+  color: #94a3b8;
+}
+
+.state {
+  padding: 48px;
+  text-align: center;
+  color: #64748b;
+}
+
+.state.error {
+  color: #b91c1c;
+}
+
+.table-wrap {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+
+th,
+td {
+  padding: 12px 16px;
+  text-align: left;
+  border-bottom: 1px solid #eef2f7;
+  vertical-align: top;
+}
+
+th {
+  position: sticky;
+  top: 0;
+  background: #f8fafc;
+  font-size: 12px;
+  font-weight: 600;
+  color: #64748b;
+  z-index: 1;
+}
+
+.col-time {
+  width: 180px;
+}
+
+.col-name {
+  width: 280px;
+}
+
+.row {
+  cursor: pointer;
+}
+
+.row:hover td {
+  background: #f1f5f9;
+}
+
+.path {
+  color: #334155;
+  word-break: break-all;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+</style>

+ 3 - 0
web/vite.config.ts

@@ -4,7 +4,10 @@ import vue from '@vitejs/plugin-vue'
 export default defineConfig({
   plugins: [vue()],
   server: {
+    host: '0.0.0.0',
     port: 5173,
+    strictPort: true,
+    allowedHosts: true,
     proxy: {
       '/api': {
         target: 'http://127.0.0.1:8080',