Procházet zdrojové kódy

修改数据和可视化页面

xueyiming před 19 hodinami
rodič
revize
da9b3de94d

+ 76 - 0
alembic/versions/20260820_25_add_category_audience_aggregates.py

@@ -0,0 +1,76 @@
+"""add account uid and category audience aggregate fields
+
+Revision ID: 20260820_25
+Revises: 20260820_24
+Create Date: 2026-08-20
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260820_25"
+down_revision: str | None = "20260820_24"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "channel_content_data",
+        sa.Column(
+            "account_uid",
+            sa.String(length=128),
+            nullable=True,
+            comment="公众号账号UID",
+        ),
+    )
+    op.create_index(
+        "idx_channel_content_data_dt_account",
+        "channel_content_data",
+        ["dt", "account_uid"],
+    )
+    op.add_column(
+        "global_category_content_weight_di",
+        sa.Column(
+            "account_uid_count",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="当前节点及全部后代节点去重后的账号数量",
+        ),
+    )
+    op.add_column(
+        "global_category_content_weight_di",
+        sa.Column(
+            "channel_content_id_count",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="当前节点及全部后代节点去重后的频道内容数量",
+        ),
+    )
+    op.add_column(
+        "global_category_content_weight_di",
+        sa.Column(
+            "cal_fans_num_sum",
+            sa.Double(),
+            server_default="0",
+            nullable=False,
+            comment="当前节点及全部后代节点按账号去重后的粉丝数之和",
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_column("global_category_content_weight_di", "cal_fans_num_sum")
+    op.drop_column("global_category_content_weight_di", "channel_content_id_count")
+    op.drop_column("global_category_content_weight_di", "account_uid_count")
+    op.drop_index(
+        "idx_channel_content_data_dt_account",
+        table_name="channel_content_data",
+    )
+    op.drop_column("channel_content_data", "account_uid")

+ 9 - 0
api/services/growth_category_tree.py

@@ -71,6 +71,15 @@ def _build_growth_tree(
                 "avg_read_rate": count,
                 "like_rate": count,
             },
+            "account_uid_count": (
+                int(getattr(weight, "account_uid_count", 0) or 0) if weight else 0
+            ),
+            "channel_content_id_count": (
+                int(getattr(weight, "channel_content_id_count", 0) or 0) if weight else 0
+            ),
+            "cal_fans_num_sum": (
+                float(getattr(weight, "cal_fans_num_sum", 0.0) or 0.0) if weight else 0.0
+            ),
             "children": children,
         }
 

+ 6 - 9
supply_infra/db/models/channel_content_data.py

@@ -20,18 +20,15 @@ class ChannelContentData(Base):
     )
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    account_uid: Mapped[str | None] = mapped_column(
+        String(128), nullable=True, comment="公众号账号UID"
+    )
     channel_content_id: Mapped[str] = mapped_column(String(128), nullable=False)
     read_cnt: Mapped[float | None] = mapped_column(Double, nullable=True)
     like_cnt: Mapped[float | None] = mapped_column(Double, nullable=True)
     avg_read_cnt_30d: Mapped[float | None] = mapped_column(Double, nullable=True)
     cal_fans_num: Mapped[float | None] = mapped_column(Double, nullable=True)
-    avg_read_rate: Mapped[float] = mapped_column(
-        Double, nullable=False, server_default=text("0")
-    )
-    like_rate: Mapped[float] = mapped_column(
-        Double, nullable=False, server_default=text("0")
-    )
-    read_rate: Mapped[float] = mapped_column(
-        Double, nullable=False, server_default=text("0")
-    )
+    avg_read_rate: Mapped[float] = mapped_column(Double, nullable=False, server_default=text("0"))
+    like_rate: Mapped[float] = mapped_column(Double, nullable=False, server_default=text("0"))
+    read_rate: Mapped[float] = mapped_column(Double, nullable=False, server_default=text("0"))
     dt: Mapped[str] = mapped_column(String(8), nullable=False)

+ 18 - 0
supply_infra/db/models/global_category_content_weight.py

@@ -72,6 +72,24 @@ class GlobalCategoryContentWeight(Base):
         server_default=text("0"),
         comment="当前节点及全部后代节点参与计算的明细数量",
     )
+    account_uid_count: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default=text("0"),
+        comment="当前节点及全部后代节点去重后的账号数量",
+    )
+    channel_content_id_count: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default=text("0"),
+        comment="当前节点及全部后代节点去重后的频道内容数量",
+    )
+    cal_fans_num_sum: Mapped[float] = mapped_column(
+        Double,
+        nullable=False,
+        server_default=text("0"),
+        comment="当前节点及全部后代节点按账号去重后的粉丝数之和",
+    )
     direct_contribution_sum: Mapped[float] = mapped_column(
         Double,
         nullable=False,

+ 11 - 0
supply_infra/db/repositories/channel_content_data_repo.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import Any
 
+from sqlalchemy import func
 from sqlalchemy.dialects.mysql import insert
 from sqlalchemy.orm import Session
 
@@ -22,4 +23,14 @@ class ChannelContentDataRepository:
                 insert(ChannelContentData).values(batch).prefix_with("IGNORE")
             )
             inserted += int(result.rowcount or 0)
+            account_batch = [row for row in batch if row.get("account_uid") is not None]
+            if account_batch:
+                statement = insert(ChannelContentData).values(account_batch)
+                statement = statement.on_duplicate_key_update(
+                    account_uid=func.coalesce(
+                        ChannelContentData.account_uid,
+                        statement.inserted.account_uid,
+                    )
+                )
+                self.session.execute(statement)
         return inserted

+ 37 - 0
supply_infra/db/repositories/global_category_content_weight_repo.py

@@ -87,6 +87,36 @@ class GlobalCategoryContentWeightRepository:
             for row in self.session.execute(stmt).all()
         }
 
+    def load_direct_audience_dimensions(self, biz_dt: str) -> list[dict[str, Any]]:
+        rows = self.session.execute(
+            select(
+                GlobalSourceElementData.global_category_stable_id.label("stable_id"),
+                ChannelContentData.channel_content_id,
+                ChannelContentData.account_uid,
+                ChannelContentData.cal_fans_num,
+            )
+            .join(
+                ChannelContentData,
+                (ChannelContentData.channel_content_id == GlobalSourceElementData.post_id)
+                & (ChannelContentData.dt == biz_dt),
+            )
+            .where(
+                GlobalSourceElementData.contribution.is_not(None),
+                GlobalSourceElementData.global_category_stable_id.is_not(None),
+                GlobalSourceElementData.post_id.is_not(None),
+            )
+            .distinct()
+        ).all()
+        return [
+            {
+                "stable_id": int(row.stable_id),
+                "channel_content_id": str(row.channel_content_id),
+                "account_uid": (str(row.account_uid) if row.account_uid is not None else None),
+                "cal_fans_num": float(row.cal_fans_num or 0.0),
+            }
+            for row in rows
+        ]
+
     def initialize_day(self, biz_dt: str) -> dict[str, int]:
         existing = self.count_by_biz_dt(biz_dt)
         if existing:
@@ -156,6 +186,9 @@ class GlobalCategoryContentWeightRepository:
             "attempt_count",
             "direct_source_element_count",
             "source_element_count",
+            "account_uid_count",
+            "channel_content_id_count",
+            "cal_fans_num_sum",
             "direct_contribution_sum",
             "contribution_sum",
             "direct_avg_read_rate_weighted_sum",
@@ -173,3 +206,7 @@ class GlobalCategoryContentWeightRepository:
     def save_completed(self, rows: list[dict[str, Any]]) -> None:
         if rows:
             self.session.bulk_update_mappings(GlobalCategoryContentWeight, rows)
+
+    def save_audience_aggregates(self, rows: list[dict[str, Any]]) -> None:
+        if rows:
+            self.session.bulk_update_mappings(GlobalCategoryContentWeight, rows)

+ 5 - 11
supply_infra/odps/client.py

@@ -30,9 +30,7 @@ class ODPSClient:
             try:
                 from odps import ODPS
             except ImportError as e:
-                raise ImportError(
-                    "pyodps 未安装,请执行: pip install pyodps"
-                ) from e
+                raise ImportError("pyodps 未安装,请执行: pip install pyodps") from e
             self._client = ODPS(
                 self.access_id,
                 self.access_key,
@@ -51,7 +49,6 @@ class ODPSClient:
             return [dict(zip(columns, row.values)) for row in reader]
         return []
 
-
     def fetch_pattern_mining_elements(self, bizdate: str) -> list[dict[str, Any]]:
         """拉取 pattern_mining_element 元素(name, category_id)。"""
         sql = f"""
@@ -127,9 +124,7 @@ class ODPSClient:
         """按 vid 批量拉取 dwd_topic_decode_result_di 的 vid、url1、url2、decode_result。"""
         # 保序去重
         unique_vids = list(
-            dict.fromkeys(
-                str(v).strip() for v in vids if v is not None and str(v).strip()
-            )
+            dict.fromkeys(str(v).strip() for v in vids if v is not None and str(v).strip())
         )
         if not unique_vids:
             return []
@@ -289,9 +284,7 @@ class ODPSClient:
         """
         return self.execute_sql(sql)
 
-    def fetch_global_source_element_data(
-        self, bizdate: str
-    ) -> list[dict[str, Any]]:
+    def fetch_global_source_element_data(self, bizdate: str) -> list[dict[str, Any]]:
         """Fetch source-element mappings and their intent weights."""
         sql = f"""
         SELECT  t1.global_element_id
@@ -322,7 +315,8 @@ class ODPSClient:
     def fetch_channel_content_data(self, bizdate: str) -> list[dict[str, Any]]:
         """Fetch one daily WeChat article read-rate partition."""
         sql = f"""
-        SELECT  channel_content_id
+        SELECT  account_uid
+                ,channel_content_id
                 ,read_cnt
                 ,like_cnt
                 ,avg_read_cnt_30d

+ 59 - 0
supply_infra/scheduler/jobs/compute_global_category_content_weights.py

@@ -67,6 +67,56 @@ def _score(numerator: float, contribution_sum: float) -> float:
     return numerator / contribution_sum
 
 
+def _rollup_audience_dimensions(
+    rows_by_id: dict[int, dict[str, Any]],
+    direct_rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+    direct_channels: dict[int, set[str]] = defaultdict(set)
+    direct_accounts: dict[int, dict[str, float]] = defaultdict(dict)
+    for row in direct_rows:
+        stable_id = int(row["stable_id"])
+        if stable_id not in rows_by_id:
+            continue
+        direct_channels[stable_id].add(str(row["channel_content_id"]))
+        account_uid = row.get("account_uid")
+        if account_uid is None or str(account_uid) == "":
+            continue
+        account_key = str(account_uid)
+        fans = float(row.get("cal_fans_num") or 0.0)
+        direct_accounts[stable_id][account_key] = max(
+            direct_accounts[stable_id].get(account_key, 0.0),
+            fans,
+        )
+
+    channels_by_node = dict(direct_channels)
+    accounts_by_node = dict(direct_accounts)
+    updates: list[dict[str, Any]] = []
+    for stable_id in _postorder(rows_by_id):
+        channels = channels_by_node.pop(stable_id, set())
+        accounts = accounts_by_node.pop(stable_id, {})
+        updates.append(
+            {
+                "id": int(rows_by_id[stable_id]["id"]),
+                "account_uid_count": len(accounts),
+                "channel_content_id_count": len(channels),
+                "cal_fans_num_sum": sum(accounts.values()),
+            }
+        )
+
+        parent = rows_by_id[stable_id].get("parent_stable_id")
+        if parent is None or int(parent) not in rows_by_id:
+            continue
+        parent_id = int(parent)
+        channels_by_node.setdefault(parent_id, set()).update(channels)
+        parent_accounts = accounts_by_node.setdefault(parent_id, {})
+        for account_uid, fans in accounts.items():
+            parent_accounts[account_uid] = max(
+                parent_accounts.get(account_uid, 0.0),
+                fans,
+            )
+    return updates
+
+
 def compute_global_category_content_weights(
     biz_dt: str | None = None,
 ) -> dict[str, Any]:
@@ -151,6 +201,14 @@ def compute_global_category_content_weights(
             )
     flush()
 
+    with get_session() as session:
+        direct_audience_rows = GlobalCategoryContentWeightRepository(
+            session
+        ).load_direct_audience_dimensions(resolved_dt)
+    audience_updates = _rollup_audience_dimensions(rows_by_id, direct_audience_rows)
+    with get_session() as session:
+        GlobalCategoryContentWeightRepository(session).save_audience_aggregates(audience_updates)
+
     return {
         "success": True,
         "biz_dt": resolved_dt,
@@ -159,6 +217,7 @@ def compute_global_category_content_weights(
         "completed_before": completed_before,
         "completed_now": completed_now,
         "completed_total": completed_before + completed_now,
+        "audience_aggregated_nodes": len(audience_updates),
     }
 
 

+ 6 - 0
tests/api/test_growth_category_tree.py

@@ -54,6 +54,9 @@ def test_build_growth_tree_uses_stable_ids_and_daily_scores() -> None:
             read_rate_score=0.3,
             avg_read_rate_score=1.2,
             like_rate_score=0.04,
+            account_uid_count=2,
+            channel_content_id_count=5,
+            cal_fans_num_sum=123.45,
         ),
         SimpleNamespace(
             stable_id=11,
@@ -78,6 +81,9 @@ def test_build_growth_tree_uses_stable_ids_and_daily_scores() -> None:
         "avg_read_rate": 8,
         "like_rate": 8,
     }
+    assert root["account_uid_count"] == 2
+    assert root["channel_content_id_count"] == 5
+    assert root["cal_fans_num_sum"] == 123.45
     assert root["children"][0]["id"] == 11
     assert root["children"][0]["counts"]["read_rate"] == 0
     assert "hung_word_count" not in root

+ 53 - 0
tests/supply_infra/scheduler/test_global_category_content_weights.py

@@ -7,6 +7,7 @@ import pytest
 
 from supply_infra.scheduler.jobs.compute_global_category_content_weights import (
     _postorder,
+    _rollup_audience_dimensions,
     compute_global_category_content_weights,
 )
 
@@ -81,6 +82,58 @@ def test_postorder_places_descendants_before_parent() -> None:
     assert _postorder(rows) == [3, 2, 1]
 
 
+def test_audience_dimensions_are_deduplicated_across_descendants() -> None:
+    rows = {
+        1: _row(
+            1,
+            1,
+            None,
+            direct_count=0,
+            contribution=0,
+            avg_numerator=0,
+            like_numerator=0,
+            read_numerator=0,
+        ),
+        2: _row(
+            2,
+            2,
+            1,
+            direct_count=0,
+            contribution=0,
+            avg_numerator=0,
+            like_numerator=0,
+            read_numerator=0,
+        ),
+    }
+    direct_rows = [
+        {
+            "stable_id": 1,
+            "channel_content_id": "content-1",
+            "account_uid": "account-a",
+            "cal_fans_num": 100,
+        },
+        {
+            "stable_id": 2,
+            "channel_content_id": "content-2",
+            "account_uid": "account-a",
+            "cal_fans_num": 120,
+        },
+        {
+            "stable_id": 2,
+            "channel_content_id": "content-1",
+            "account_uid": "account-b",
+            "cal_fans_num": 50,
+        },
+    ]
+
+    updates = _rollup_audience_dimensions(rows, direct_rows)
+
+    by_id = {row["id"]: row for row in updates}
+    assert by_id[1]["account_uid_count"] == 2
+    assert by_id[1]["channel_content_id_count"] == 2
+    assert by_id[1]["cal_fans_num_sum"] == pytest.approx(170)
+
+
 @patch(
     "supply_infra.scheduler.jobs.compute_global_category_content_weights.GlobalCategoryContentWeightRepository"
 )

+ 2 - 0
tests/supply_infra/scheduler/test_global_v2_snapshot.py

@@ -24,6 +24,8 @@ def test_channel_content_query_guards_zero_and_null_denominators() -> None:
         client.fetch_channel_content_data("20260819")
 
     sql = execute_sql.call_args.args[0]
+    assert "SELECT  account_uid" in sql
+    assert ",channel_content_id" in sql
     assert "avg_read_cnt_30d IS NULL" in sql
     assert "avg_read_cnt_30d = 0" in sql
     assert "read_cnt = 0" in sql

+ 259 - 10
web/src/components/IcicleHeatTree.vue

@@ -101,6 +101,9 @@ const activeTab = ref<HeatTabKey>(props.initialTab)
 const startDepth = ref(0)
 const focus = ref<PreparedNode | null>(null)
 const selectedNode = ref<PreparedNode | null>(null)
+const searchQuery = ref('')
+const searchOpen = ref(false)
+const searchHighlightedNodeId = ref<number | null>(null)
 
 /** Vertical zoom only — column width stays fixed at COLUMN_WIDTH. */
 const viewScaleY = ref(1)
@@ -233,6 +236,33 @@ const preparedNodeById = computed(() => {
   return m
 })
 
+const searchResults = computed(() => {
+  const keyword = searchQuery.value.trim().toLocaleLowerCase('zh-CN')
+  if (!keyword) return []
+
+  return prepared.value.flat
+    .map((node) => {
+      const name = node.name.toLocaleLowerCase('zh-CN')
+      const path = node.path.join(' / ').toLocaleLowerCase('zh-CN')
+      let score = 4
+      if (name === keyword) score = 0
+      else if (name.startsWith(keyword)) score = 1
+      else if (name.includes(keyword)) score = 2
+      else if (path.includes(keyword)) score = 3
+      else return null
+      return { node, score }
+    })
+    .filter((item): item is { node: PreparedNode; score: number } => item != null)
+    .sort(
+      (a, b) =>
+        a.score - b.score ||
+        a.node.path.length - b.node.path.length ||
+        a.node.name.localeCompare(b.node.name, 'zh-CN'),
+    )
+    .slice(0, 20)
+    .map((item) => item.node)
+})
+
 const demandCards = computed<DemandGradeListCard[]>(() => {
   // props.demandsByCategory: category_id -> DemandGradeItem[]
   const metaById = new Map<
@@ -405,7 +435,7 @@ const mapContext = computed(() => {
     return `${focus.value.path.join(' / ')} · 当前分支 · 共 ${total} 个节点${heatHint}`
   }
   if (startDepth.value > 0) {
-    return `从第 ${startDepth.value} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
+    return `从第 ${startDepth.value + 1} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
   }
   return `全局视图 · 横向为层级,纵向为分支规模 · ${total} 个节点${heatHint}`
 })
@@ -478,6 +508,14 @@ function onTabClick(key: HeatTabKey) {
   emit('tabChange', key)
 }
 
+function formatAggregateNumber(value: number | null | undefined): string {
+  if (value == null || Number.isNaN(value)) return '0'
+  return value.toLocaleString('zh-CN', {
+    minimumFractionDigits: 0,
+    maximumFractionDigits: 2,
+  })
+}
+
 function resetViewTransform() {
   viewScaleY.value = 1
   viewY.value = 0
@@ -487,6 +525,7 @@ function resetGlobalView() {
   startDepth.value = 0
   focus.value = null
   selectedNode.value = null
+  searchHighlightedNodeId.value = null
   clearDemandListSelection()
   closeInspect()
   hideTooltip()
@@ -504,9 +543,10 @@ function resetGlobalView() {
 function selectNode(
   node: PreparedNode,
   withFocus = false,
-  source: 'canvas' | 'demandList' = 'canvas',
+  source: 'canvas' | 'demandList' | 'search' = 'canvas',
 ) {
-  if (source === 'canvas') clearDemandListSelection()
+  if (source !== 'demandList') clearDemandListSelection()
+  searchHighlightedNodeId.value = source === 'search' ? node.id : null
   selectedNode.value = node
   emit('nodeSelected', { id: node.id, name: node.name })
   if (withFocus) {
@@ -539,6 +579,30 @@ function selectNode(
   scheduleDraw()
 }
 
+function selectSearchResult(node: PreparedNode) {
+  searchQuery.value = node.name
+  searchOpen.value = false
+  selectNode(node, true, 'search')
+}
+
+function onSearchEnter() {
+  const first = searchResults.value[0]
+  if (first) selectSearchResult(first)
+}
+
+function onSearchInput() {
+  searchOpen.value = true
+  searchHighlightedNodeId.value = null
+  scheduleDraw()
+}
+
+function clearSearch() {
+  searchQuery.value = ''
+  searchOpen.value = false
+  searchHighlightedNodeId.value = null
+  scheduleDraw()
+}
+
 function closeInspect() {
   inspectOpen.value = false
   inspectNode.value = null
@@ -551,6 +615,7 @@ function onDepthClick(depth: number) {
   }
   focus.value = null
   selectedNode.value = null
+  searchHighlightedNodeId.value = null
   clearDemandListSelection()
   closeInspect()
   startDepth.value = depth
@@ -724,6 +789,7 @@ function draw() {
   const vx1 = vw + pad
   const vy1 = vh + pad
   const selected = selectedNode.value
+  const searchHighlightedId = searchHighlightedNodeId.value
   const strokeColor = activeDim.value ? 'rgba(15,23,42,.08)' : 'rgba(255,255,255,.88)'
 
   drawRects = []
@@ -748,9 +814,20 @@ function draw() {
       ctx.strokeRect(x, y, width, height)
     }
 
+    if (searchHighlightedId === item.node.id) {
+      ctx.strokeStyle = '#f59e0b'
+      ctx.lineWidth = 6
+      ctx.strokeRect(
+        x + 3,
+        y + 3,
+        Math.max(0, width - 6),
+        Math.max(0, height - 6),
+      )
+    }
+
     if (selected === item.node) {
       ctx.strokeStyle = '#7c3aed'
-      ctx.lineWidth = 3
+      ctx.lineWidth = 2
       ctx.strokeRect(
         x + 1.5,
         y + 1.5,
@@ -1005,10 +1082,53 @@ onUnmounted(() => {
 
       <div class="map-toolbar">
         <p class="map-context">{{ mapContext }}</p>
-        <div class="view-actions">
-          <button type="button" class="btn btn-secondary" @click="resetGlobalView">
-            回到全局
-          </button>
+        <div class="map-toolbar-actions">
+          <div class="node-search" role="search">
+            <span class="node-search-icon" aria-hidden="true">⌕</span>
+            <input
+              v-model="searchQuery"
+              type="search"
+              autocomplete="off"
+              placeholder="搜索节点名称"
+              aria-label="搜索分类节点"
+              @focus="searchOpen = true"
+              @input="onSearchInput"
+              @blur="searchOpen = false"
+              @keydown.enter.prevent="onSearchEnter"
+              @keydown.esc.prevent="searchOpen = false"
+            >
+            <button
+              v-if="searchQuery"
+              type="button"
+              class="node-search-clear"
+              aria-label="清空搜索"
+              @click="clearSearch"
+            >
+              ×
+            </button>
+            <div v-if="searchOpen && searchQuery.trim()" class="node-search-results">
+              <button
+                v-for="node in searchResults"
+                :key="node.id"
+                type="button"
+                class="node-search-result"
+                @mousedown.prevent="selectSearchResult(node)"
+              >
+                <span class="node-search-name">{{ node.name }}</span>
+                <span class="node-search-path">
+                  第 {{ node.path.length }} 级 · {{ node.path.join(' / ') }}
+                </span>
+              </button>
+              <div v-if="!searchResults.length" class="node-search-empty">
+                未找到匹配节点
+              </div>
+            </div>
+          </div>
+          <div class="view-actions">
+            <button type="button" class="btn btn-secondary" @click="resetGlobalView">
+              回到全局
+            </button>
+          </div>
         </div>
       </div>
 
@@ -1023,7 +1143,7 @@ onUnmounted(() => {
           :class="{ active: depth - 1 === baseDepth }"
           @click="onDepthClick(depth - 1)"
         >
-          {{ depth - 1 === 0 ? '根' : `第 ${depth - 1} 级` }}
+          第 {{ depth }} 级
         </button>
       </div>
 
@@ -1076,10 +1196,15 @@ onUnmounted(() => {
               {{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
             </span>
             <span>
-              第 {{ tooltipNode.path.length - 1 }} 级
+              第 {{ tooltipNode.path.length }} 级
               · {{ tooltipNode.children.length }} 个子节点
               · {{ tooltipNode.leafCount }} 个叶节点
             </span>
+            <span>去重账号数:{{ formatAggregateNumber(tooltipNode.account_uid_count) }}</span>
+            <span>
+              去重文章数:{{ formatAggregateNumber(tooltipNode.channel_content_id_count) }}
+            </span>
+            <span>粉丝数合计:{{ formatAggregateNumber(tooltipNode.cal_fans_num_sum) }}</span>
             <span v-if="tooltipNode.hung_word_count != null">
               挂靠词 {{ tooltipNode.hung_word_count }}
             </span>
@@ -1432,6 +1557,130 @@ onUnmounted(() => {
   gap: 8px;
 }
 
+.map-toolbar-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.node-search {
+  position: relative;
+  display: flex;
+  align-items: center;
+  width: min(360px, 38vw);
+  min-width: 220px;
+}
+
+.node-search-icon {
+  position: absolute;
+  left: 10px;
+  z-index: 1;
+  color: #64748b;
+  font-size: 18px;
+  line-height: 1;
+  pointer-events: none;
+}
+
+.node-search input {
+  width: 100%;
+  height: 34px;
+  padding: 0 32px 0 34px;
+  border: 1px solid #cbd5e1;
+  border-radius: 7px;
+  outline: none;
+  background: #fff;
+  color: #0f172a;
+  font-size: 13px;
+}
+
+.node-search input:focus {
+  border-color: #7c3aed;
+  box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
+}
+
+.node-search-clear {
+  position: absolute;
+  right: 5px;
+  width: 25px;
+  height: 25px;
+  padding: 0;
+  border: 0;
+  border-radius: 5px;
+  background: transparent;
+  color: #64748b;
+  font-size: 18px;
+  cursor: pointer;
+}
+
+.node-search-clear:hover {
+  background: #f1f5f9;
+  color: #0f172a;
+}
+
+.node-search-results {
+  position: absolute;
+  top: calc(100% + 6px);
+  right: 0;
+  z-index: 20;
+  width: max(100%, 360px);
+  max-height: 360px;
+  overflow-y: auto;
+  padding: 5px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18);
+}
+
+.node-search-result {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  width: 100%;
+  padding: 8px 10px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+
+.node-search-result:hover {
+  background: #f5f3ff;
+}
+
+.node-search-name {
+  color: #0f172a;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.node-search-path {
+  overflow: hidden;
+  color: #64748b;
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.node-search-empty {
+  padding: 20px 12px;
+  color: #94a3b8;
+  font-size: 13px;
+  text-align: center;
+}
+
+@media (max-width: 720px) {
+  .map-toolbar-actions,
+  .node-search {
+    width: 100%;
+  }
+
+  .node-search-results {
+    width: 100%;
+  }
+}
+
 .btn {
   height: 32px;
   padding: 0 12px;

+ 3 - 0
web/src/types/category.ts

@@ -26,6 +26,9 @@ export interface CategoryNode {
   weights?: Partial<NodeWeights>
   counts?: Partial<NodeCounts>
   hung_word_count?: number
+  account_uid_count?: number
+  channel_content_id_count?: number
+  cal_fans_num_sum?: number
   children: CategoryNode[]
 }
 

+ 6 - 0
web/src/types/heatTree.ts

@@ -7,6 +7,9 @@ export interface PreparedNode {
   level: number | null
   description: string | null
   hung_word_count?: number
+  account_uid_count?: number
+  channel_content_id_count?: number
+  cal_fans_num_sum?: number
   weights?: Partial<NodeWeights>
   counts?: Partial<NodeCounts>
   parent: PreparedNode | null
@@ -40,6 +43,9 @@ function toPreparedNode(node: CategoryNode): Omit<PreparedNode, 'parent' | 'path
     level: node.level,
     description: node.description,
     hung_word_count: node.hung_word_count,
+    account_uid_count: node.account_uid_count,
+    channel_content_id_count: node.channel_content_id_count,
+    cal_fans_num_sum: node.cal_fans_num_sum,
     weights: node.weights,
     counts: node.counts,
     children: [],