Просмотр исходного кода

增加top10查看和详情查看

xueyiming 8 часов назад
Родитель
Сommit
db5f7124d2

+ 7 - 0
.env.example

@@ -43,6 +43,13 @@ MYSQL_WRITE_TIMEOUT_SECONDS=30
 MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 
+# AIGC article visualization database (read-only account)
+AIGC_READONLY_MYSQL_HOST=
+AIGC_READONLY_MYSQL_PORT=3306
+AIGC_READONLY_MYSQL_USER=
+AIGC_READONLY_MYSQL_PASSWORD=
+AIGC_READONLY_MYSQL_DATABASE=
+
 # Local web authentication. The bootstrap account is only created when the
 # username does not already exist; changing these values will not reset it.
 AUTH_SESSION_HOURS=12

+ 129 - 0
alembic/versions/20260820_23_add_category_channel_content_rank.py

@@ -0,0 +1,129 @@
+"""add daily category channel-content Top 10 ranking table
+
+Revision ID: 20260820_23
+Revises: 20260820_22
+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_23"
+down_revision: str | None = "20260820_22"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "global_category_channel_content_rank_di",
+        sa.Column(
+            "id",
+            sa.BigInteger(),
+            autoincrement=True,
+            nullable=False,
+            comment="自增主键",
+        ),
+        sa.Column(
+            "biz_dt",
+            sa.String(length=8),
+            nullable=False,
+            comment="业务日期,格式YYYYMMDD",
+        ),
+        sa.Column(
+            "stable_id",
+            sa.BigInteger(),
+            nullable=False,
+            comment="全局分类节点稳定ID",
+        ),
+        sa.Column(
+            "metric_type",
+            sa.String(length=32),
+            nullable=False,
+            comment="指标类型:read_rate、avg_read_rate、like_rate",
+        ),
+        sa.Column(
+            "rank_no",
+            sa.Integer(),
+            nullable=False,
+            comment="节点内当前指标排名,范围1至10",
+        ),
+        sa.Column(
+            "channel_content_id",
+            sa.String(length=128),
+            nullable=False,
+            comment="频道内容ID",
+        ),
+        sa.Column(
+            "source_element_count",
+            sa.BigInteger(),
+            nullable=False,
+            comment="节点子树内关联的有效元素数量",
+        ),
+        sa.Column(
+            "contribution_sum",
+            sa.Double(),
+            nullable=False,
+            comment="节点子树内该内容的contribution之和",
+        ),
+        sa.Column(
+            "metric_value",
+            sa.Double(),
+            nullable=False,
+            comment="内容当天对应指标的加权平均原始值",
+        ),
+        sa.Column(
+            "weighted_score",
+            sa.Double(),
+            nullable=False,
+            comment="指标值乘contribution后的加权贡献和",
+        ),
+        sa.Column(
+            "weighted_share",
+            sa.Double(),
+            nullable=False,
+            comment="该内容加权贡献和占节点该指标加权和的比例",
+        ),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+            comment="创建时间",
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+            comment="更新时间",
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "channel_content_id",
+            name="uk_global_category_channel_content_rank_content",
+        ),
+        sa.UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+            name="uk_global_category_channel_content_rank_no",
+        ),
+    )
+    op.create_index(
+        "idx_global_category_channel_content_rank_query",
+        "global_category_channel_content_rank_di",
+        ["biz_dt", "stable_id", "metric_type", "rank_no"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("global_category_channel_content_rank_di")

+ 77 - 0
alembic/versions/20260820_24_use_single_content_contribution.py

@@ -0,0 +1,77 @@
+"""use one maximum-contribution element per ranked content
+
+Revision ID: 20260820_24
+Revises: 20260820_23
+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_24"
+down_revision: str | None = "20260820_23"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+TABLE_NAME = "global_category_channel_content_rank_di"
+
+
+def upgrade() -> None:
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_element_id",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="节点子树内选中的最大contribution来源元素ID",
+        ),
+    )
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_category_stable_id",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="选中来源元素直接归属的分类节点稳定ID",
+        ),
+    )
+    op.alter_column(
+        TABLE_NAME,
+        "contribution_sum",
+        new_column_name="contribution",
+        existing_type=sa.Double(),
+        existing_nullable=False,
+        comment="选中来源元素的单个contribution",
+        existing_comment="节点子树内该内容的contribution之和",
+    )
+    op.drop_column(TABLE_NAME, "source_element_count")
+
+
+def downgrade() -> None:
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_element_count",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="节点子树内关联的有效元素数量",
+        ),
+    )
+    op.alter_column(
+        TABLE_NAME,
+        "contribution",
+        new_column_name="contribution_sum",
+        existing_type=sa.Double(),
+        existing_nullable=False,
+        comment="节点子树内该内容的contribution之和",
+        existing_comment="选中来源元素的单个contribution",
+    )
+    op.drop_column(TABLE_NAME, "source_category_stable_id")
+    op.drop_column(TABLE_NAME, "source_element_id")

+ 35 - 1
api/app.py

@@ -1,8 +1,10 @@
 """FastAPI application — category tree API on port 8080."""
+
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
 from pathlib import Path
+from urllib.parse import urlparse
 
 from typing import Literal
 
@@ -12,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from sqlalchemy import text
 from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.responses import RedirectResponse
 
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
@@ -25,7 +28,11 @@ from api.services.agent_catalog import (
 )
 from api.services.auth import ensure_bootstrap_admin
 from api.services.category_tree import build_category_tree
-from api.services.growth_category_tree import build_growth_category_tree
+from api.services.aigc_article_html import get_latest_article_html
+from api.services.growth_category_tree import (
+    build_growth_category_tree,
+    list_growth_category_channel_contents,
+)
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
@@ -236,6 +243,33 @@ def growth_category_tree(
     return build_growth_category_tree(biz_dt=biz_dt)
 
 
+@app.get("/api/growth-category-tree/{stable_id}/channel-contents")
+def growth_category_channel_contents(
+    stable_id: int,
+    metric: Literal["read_rate", "avg_read_rate", "like_rate"] = Query(...),
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+) -> dict:
+    """Return the selected metric's daily Top 10 contents for one node."""
+    return list_growth_category_channel_contents(
+        stable_id=stable_id,
+        metric=metric,
+        biz_dt=biz_dt,
+    )
+
+
+@app.get("/api/growth-channel-content/{channel_content_id}/view")
+def growth_channel_content_view(channel_content_id: str) -> RedirectResponse:
+    """Redirect to the latest external visualization page for one content."""
+    target = get_latest_article_html(channel_content_id)
+    if target is None:
+        raise HTTPException(status_code=404, detail="article visualization page not found")
+    target = target.strip()
+    parsed = urlparse(target)
+    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+        raise HTTPException(status_code=422, detail="article visualization target is not a URL")
+    return RedirectResponse(url=target, status_code=302)
+
+
 @app.get("/api/demand-belong-category")
 def demand_belong_category() -> dict:
     """Return all active demand_belong_category rows in one response."""

+ 3 - 3
api/auth_middleware.py

@@ -27,6 +27,8 @@ _NORMAL_USER_PATHS = {
     ("POST", "/api/video-discovery/feedback"),
 }
 _NORMAL_USER_PATTERNS = (
+    re.compile(r"^/api/growth-category-tree/\d+/channel-contents$"),
+    re.compile(r"^/api/growth-channel-content/[^/]+/view$"),
     re.compile(r"^/api/video-discovery/demands/\d+$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
@@ -45,9 +47,7 @@ def normal_user_can_access(method: str, path: str) -> bool:
 
 def _is_protected_path(path: str) -> bool:
     return (
-        path == "/api"
-        or path.startswith("/api/")
-        or path in {"/docs", "/redoc", "/openapi.json"}
+        path == "/api" or path.startswith("/api/") or path in {"/docs", "/redoc", "/openapi.json"}
     )
 
 

+ 62 - 0
api/services/aigc_article_html.py

@@ -0,0 +1,62 @@
+"""Read the latest article visualization HTML from the external AIGC database."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import Any
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.pool import QueuePool
+
+from supply_infra.config import get_infra_settings
+
+
+@lru_cache(maxsize=1)
+def _get_aigc_readonly_engine() -> Any:
+    settings = get_infra_settings()
+    if not all(
+        (
+            settings.aigc_readonly_mysql_host,
+            settings.aigc_readonly_mysql_user,
+            settings.aigc_readonly_mysql_database,
+        )
+    ):
+        raise RuntimeError("AIGC read-only MySQL connection is not configured")
+    return create_engine(
+        settings.aigc_readonly_mysql_url,
+        poolclass=QueuePool,
+        pool_size=2,
+        max_overflow=0,
+        pool_timeout=10,
+        pool_recycle=1800,
+        pool_pre_ping=True,
+        connect_args={
+            "connect_timeout": 10,
+            "read_timeout": 30,
+            "write_timeout": 30,
+        },
+    )
+
+
+def get_latest_article_html(channel_content_id: str) -> str | None:
+    statement = text(
+        """
+        SELECT t2.html
+        FROM aigc_task_input_usage t1
+        JOIN aigc_task_callback_data t2
+          ON t2.task_instance_id = t1.task_instance_id
+        WHERE t1.biz_unique_id = :channel_content_id
+        ORDER BY t2.id DESC
+        LIMIT 1
+        """
+    )
+    with _get_aigc_readonly_engine().connect() as connection:
+        value = connection.scalar(
+            statement,
+            {"channel_content_id": channel_content_id},
+        )
+    if value is None:
+        return None
+    if isinstance(value, bytes):
+        return value.decode("utf-8", errors="replace")
+    return str(value)

+ 48 - 0
api/services/growth_category_tree.py

@@ -10,6 +10,9 @@ from supply_infra.db.models.global_category_content_weight import (
     GlobalCategoryContentWeight,
 )
 from supply_infra.db.models.global_v2 import GlobalCategoryV2
+from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
+    GlobalCategoryChannelContentRankRepository,
+)
 from supply_infra.db.repositories.global_category_content_weight_repo import (
     GlobalCategoryContentWeightRepository,
 )
@@ -20,6 +23,7 @@ GROWTH_DIM_META: list[dict[str, str]] = [
     {"key": "avg_read_rate", "label": "平均阅读率"},
     {"key": "like_rate", "label": "点赞率"},
 ]
+GROWTH_DIM_KEYS = frozenset(dim["key"] for dim in GROWTH_DIM_META)
 
 
 def _normalize_parent_id(parent_id: int | None, category_ids: set[int]) -> int | None:
@@ -93,3 +97,47 @@ def build_growth_category_tree(biz_dt: str | None = None) -> dict[str, Any]:
         "dims": GROWTH_DIM_META,
         "nodes": nodes,
     }
+
+
+def list_growth_category_channel_contents(
+    *,
+    stable_id: int,
+    metric: str,
+    biz_dt: str | None = None,
+) -> dict[str, Any]:
+    if metric not in GROWTH_DIM_KEYS:
+        raise ValueError(f"Unsupported growth metric: {metric}")
+
+    with get_session() as session:
+        weight_repo = GlobalCategoryContentWeightRepository(session)
+        resolved_dt = biz_dt or weight_repo.get_latest_completed_biz_dt()
+        rows = (
+            GlobalCategoryChannelContentRankRepository(session).list_top(
+                biz_dt=resolved_dt,
+                stable_id=stable_id,
+                metric_type=metric,
+            )
+            if resolved_dt
+            else []
+        )
+        items = [
+            {
+                "rank_no": int(row.rank_no),
+                "channel_content_id": row.channel_content_id,
+                "source_element_id": int(row.source_element_id),
+                "source_category_stable_id": int(row.source_category_stable_id),
+                "contribution": float(row.contribution),
+                "metric_value": float(row.metric_value),
+                "weighted_score": float(row.weighted_score),
+                "weighted_share": float(row.weighted_share),
+            }
+            for row in rows
+        ]
+
+    return {
+        "biz_dt": resolved_dt,
+        "stable_id": stable_id,
+        "metric": metric,
+        "total": len(items),
+        "items": items,
+    }

+ 33 - 3
supply_infra/config.py

@@ -73,6 +73,28 @@ class InfraSettings(BaseSettings):
     )
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
 
+    # Read-only AIGC article visualization database
+    aigc_readonly_mysql_host: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_HOST",
+    )
+    aigc_readonly_mysql_port: int = Field(
+        default=3306,
+        alias="AIGC_READONLY_MYSQL_PORT",
+    )
+    aigc_readonly_mysql_user: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_USER",
+    )
+    aigc_readonly_mysql_password: SecretStr = Field(
+        default=SecretStr(""),
+        alias="AIGC_READONLY_MYSQL_PASSWORD",
+    )
+    aigc_readonly_mysql_database: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_DATABASE",
+    )
+
     # Local web authentication
     auth_session_hours: int = Field(default=12, ge=1, le=168, alias="AUTH_SESSION_HOURS")
     auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
@@ -264,9 +286,7 @@ class InfraSettings(BaseSettings):
                 "PIPELINE_HEARTBEAT_SECONDS must be smaller than PIPELINE_LEASE_SECONDS"
             )
         if self.pipeline_max_active_steps > self.pipeline_worker_processes:
-            raise ValueError(
-                "PIPELINE_MAX_ACTIVE_STEPS cannot exceed PIPELINE_WORKER_PROCESSES"
-            )
+            raise ValueError("PIPELINE_MAX_ACTIVE_STEPS cannot exceed PIPELINE_WORKER_PROCESSES")
         if self.mysql_max_overflow != 0:
             raise ValueError("MYSQL_MAX_OVERFLOW must remain 0 for connection budgeting")
         required = self.pipeline_required_connections
@@ -295,6 +315,16 @@ class InfraSettings(BaseSettings):
             return self.mysql_pool_size_control
         return self.mysql_pool_size
 
+    @property
+    def aigc_readonly_mysql_url(self) -> str:
+        user = quote_plus(self.aigc_readonly_mysql_user)
+        password = quote_plus(self.aigc_readonly_mysql_password.get_secret_value())
+        return (
+            f"mysql+pymysql://{user}:{password}"
+            f"@{self.aigc_readonly_mysql_host}:{self.aigc_readonly_mysql_port}"
+            f"/{self.aigc_readonly_mysql_database}?charset=utf8mb4"
+        )
+
     @property
     def pipeline_required_connections(self) -> int:
         control_processes = 2  # scheduler + reconciler

+ 4 - 0
supply_infra/db/models/__init__.py

@@ -26,6 +26,9 @@ from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.global_category_content_weight import (
     GlobalCategoryContentWeight,
 )
+from supply_infra.db.models.global_category_channel_content_rank import (
+    GlobalCategoryChannelContentRank,
+)
 from supply_infra.db.models.global_v2 import (
     GlobalCategoryV2,
     GlobalElementV2,
@@ -69,6 +72,7 @@ __all__ = [
     "GlobalTreeCategory",
     "GlobalTreeElement",
     "GlobalCategoryContentWeight",
+    "GlobalCategoryChannelContentRank",
     "GlobalCategoryV2",
     "GlobalElementV2",
     "GlobalSourceElementData",

+ 95 - 0
supply_infra/db/models/global_category_channel_content_rank.py

@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import (
+    BigInteger,
+    DateTime,
+    Double,
+    Index,
+    Integer,
+    String,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class GlobalCategoryChannelContentRank(Base):
+    """Daily Top 10 channel contents contributing to each category metric."""
+
+    __tablename__ = "global_category_channel_content_rank_di"
+    __table_args__ = (
+        UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "channel_content_id",
+            name="uk_global_category_channel_content_rank_content",
+        ),
+        UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+            name="uk_global_category_channel_content_rank_no",
+        ),
+        Index(
+            "idx_global_category_channel_content_rank_query",
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(
+        BigInteger, primary_key=True, autoincrement=True, comment="自增主键"
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False, comment="业务日期,格式YYYYMMDD")
+    stable_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="全局分类节点稳定ID")
+    metric_type: Mapped[str] = mapped_column(
+        String(32), nullable=False, comment="指标类型:read_rate、avg_read_rate、like_rate"
+    )
+    rank_no: Mapped[int] = mapped_column(
+        Integer, nullable=False, comment="节点内当前指标排名,范围1至10"
+    )
+    channel_content_id: Mapped[str] = mapped_column(
+        String(128), nullable=False, comment="频道内容ID"
+    )
+    source_element_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default="0",
+        comment="节点子树内选中的最大contribution来源元素ID",
+    )
+    source_category_stable_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default="0",
+        comment="选中来源元素直接归属的分类节点稳定ID",
+    )
+    contribution: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="选中来源元素的单个contribution"
+    )
+    metric_value: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="内容当天对应指标的加权平均原始值"
+    )
+    weighted_score: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="指标值乘contribution后的加权贡献和"
+    )
+    weighted_share: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="该内容加权贡献和占节点该指标加权和的比例"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now(), comment="创建时间"
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 109 - 0
supply_infra/db/repositories/global_category_channel_content_rank_repo.py

@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.channel_content_data import ChannelContentData
+from supply_infra.db.models.global_category_channel_content_rank import (
+    GlobalCategoryChannelContentRank,
+)
+from supply_infra.db.models.global_category_content_weight import (
+    GlobalCategoryContentWeight,
+)
+from supply_infra.db.models.global_v2 import GlobalSourceElementData
+
+_INSERT_BATCH_SIZE = 1000
+
+
+class GlobalCategoryChannelContentRankRepository:
+    def __init__(self, session: Session) -> None:
+        self.session = session
+
+    def load_daily_topology(self, biz_dt: str) -> list[dict[str, Any]]:
+        rows = self.session.execute(
+            select(
+                GlobalCategoryContentWeight.stable_id,
+                GlobalCategoryContentWeight.parent_stable_id,
+                GlobalCategoryContentWeight.status,
+            ).where(GlobalCategoryContentWeight.biz_dt == biz_dt)
+        ).all()
+        return [
+            {
+                "stable_id": int(row.stable_id),
+                "parent_stable_id": (
+                    int(row.parent_stable_id) if row.parent_stable_id is not None else None
+                ),
+                "status": str(row.status),
+            }
+            for row in rows
+        ]
+
+    def load_direct_content_metrics(self, biz_dt: str) -> list[dict[str, Any]]:
+        rows = self.session.execute(
+            select(
+                GlobalSourceElementData.global_category_stable_id.label("stable_id"),
+                GlobalSourceElementData.source_element_id,
+                GlobalSourceElementData.post_id.label("channel_content_id"),
+                GlobalSourceElementData.contribution,
+                ChannelContentData.read_rate,
+                ChannelContentData.avg_read_rate,
+                ChannelContentData.like_rate,
+            )
+            .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),
+            )
+        ).all()
+        return [
+            {
+                "stable_id": int(row.stable_id),
+                "source_element_id": int(row.source_element_id),
+                "channel_content_id": str(row.channel_content_id),
+                "contribution": float(row.contribution),
+                "read_rate": float(row.read_rate or 0.0),
+                "avg_read_rate": float(row.avg_read_rate or 0.0),
+                "like_rate": float(row.like_rate or 0.0),
+            }
+            for row in rows
+        ]
+
+    def replace_day(self, biz_dt: str, rows: list[dict[str, Any]]) -> int:
+        self.session.execute(
+            delete(GlobalCategoryChannelContentRank).where(
+                GlobalCategoryChannelContentRank.biz_dt == biz_dt
+            )
+        )
+        for start in range(0, len(rows), _INSERT_BATCH_SIZE):
+            self.session.execute(
+                GlobalCategoryChannelContentRank.__table__.insert(),
+                rows[start : start + _INSERT_BATCH_SIZE],
+            )
+        return len(rows)
+
+    def list_top(
+        self,
+        *,
+        biz_dt: str,
+        stable_id: int,
+        metric_type: str,
+    ) -> list[GlobalCategoryChannelContentRank]:
+        return list(
+            self.session.scalars(
+                select(GlobalCategoryChannelContentRank)
+                .where(
+                    GlobalCategoryChannelContentRank.biz_dt == biz_dt,
+                    GlobalCategoryChannelContentRank.stable_id == stable_id,
+                    GlobalCategoryChannelContentRank.metric_type == metric_type,
+                )
+                .order_by(GlobalCategoryChannelContentRank.rank_no)
+                .limit(10)
+            ).all()
+        )

+ 206 - 0
supply_infra/scheduler/jobs/compute_global_category_channel_content_rankings.py

@@ -0,0 +1,206 @@
+"""Compute daily Top 10 channel-content contributions for every category node."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+from collections import defaultdict
+from datetime import timedelta
+from typing import Any
+
+from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
+    GlobalCategoryChannelContentRankRepository,
+)
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dates import china_now, validate_biz_dt
+from supply_infra.scheduler.cli_result import run_cli
+
+logger = logging.getLogger(__name__)
+
+_TOP_N = 10
+_METRICS = ("read_rate", "avg_read_rate", "like_rate")
+
+
+def _resolve_biz_dt(biz_dt: str | None) -> str:
+    if biz_dt:
+        return validate_biz_dt(biz_dt)
+    return (china_now() - timedelta(days=1)).strftime("%Y%m%d")
+
+
+def _postorder(topology: dict[int, int | None]) -> list[int]:
+    children: dict[int, list[int]] = defaultdict(list)
+    for stable_id, parent_id in topology.items():
+        if parent_id is not None and parent_id in topology:
+            children[parent_id].append(stable_id)
+    for child_ids in children.values():
+        child_ids.sort()
+
+    order: list[int] = []
+    visiting: set[int] = set()
+    visited: set[int] = set()
+
+    def visit(stable_id: int) -> None:
+        if stable_id in visited:
+            return
+        if stable_id in visiting:
+            raise RuntimeError(f"Category cycle detected at stable_id={stable_id}")
+        visiting.add(stable_id)
+        for child_id in children.get(stable_id, []):
+            visit(child_id)
+        visiting.remove(stable_id)
+        visited.add(stable_id)
+        order.append(stable_id)
+
+    for stable_id in sorted(topology):
+        visit(stable_id)
+    return order
+
+
+def _is_better_candidate(
+    candidate: dict[str, float | int],
+    current: dict[str, float | int] | None,
+) -> bool:
+    """Prefer the highest contribution, then the smallest source ID for stable ties."""
+    if current is None:
+        return True
+    candidate_contribution = float(candidate["contribution"])
+    current_contribution = float(current["contribution"])
+    if candidate_contribution != current_contribution:
+        return candidate_contribution > current_contribution
+    return int(candidate["source_element_id"]) < int(current["source_element_id"])
+
+
+def _build_top_rows(
+    *,
+    biz_dt: str,
+    stable_id: int,
+    content_by_id: dict[str, dict[str, float | int]],
+) -> list[dict[str, Any]]:
+    rows: list[dict[str, Any]] = []
+    for metric in _METRICS:
+        ranked = sorted(
+            content_by_id.items(),
+            key=lambda item: (
+                -(float(item[1][metric]) * float(item[1]["contribution"])),
+                item[0],
+            ),
+        )[:_TOP_N]
+        node_weighted_sum = sum(
+            float(values[metric]) * float(values["contribution"])
+            for values in content_by_id.values()
+        )
+        for index, (channel_content_id, values) in enumerate(ranked, start=1):
+            contribution = float(values["contribution"])
+            metric_value = float(values[metric])
+            weighted_score = metric_value * contribution
+            rows.append(
+                {
+                    "biz_dt": biz_dt,
+                    "stable_id": stable_id,
+                    "metric_type": metric,
+                    "rank_no": index,
+                    "channel_content_id": channel_content_id,
+                    "source_element_id": int(values["source_element_id"]),
+                    "source_category_stable_id": int(values["source_category_stable_id"]),
+                    "contribution": contribution,
+                    "metric_value": metric_value,
+                    "weighted_score": weighted_score,
+                    "weighted_share": (
+                        weighted_score / node_weighted_sum if node_weighted_sum else 0.0
+                    ),
+                }
+            )
+    return rows
+
+
+def compute_global_category_channel_content_rankings(
+    biz_dt: str | None = None,
+) -> dict[str, Any]:
+    resolved_dt = _resolve_biz_dt(biz_dt)
+    with get_session() as session:
+        repository = GlobalCategoryChannelContentRankRepository(session)
+        topology_rows = repository.load_daily_topology(resolved_dt)
+        direct_rows = repository.load_direct_content_metrics(resolved_dt)
+
+    if not topology_rows:
+        raise RuntimeError(f"No category weight snapshot for biz_dt={resolved_dt}")
+    incomplete = [int(row["stable_id"]) for row in topology_rows if row["status"] != "completed"]
+    if incomplete:
+        raise RuntimeError(
+            f"Category weights are incomplete for biz_dt={resolved_dt}: {incomplete[:10]}"
+        )
+
+    topology = {
+        int(row["stable_id"]): (
+            int(row["parent_stable_id"]) if row["parent_stable_id"] is not None else None
+        )
+        for row in topology_rows
+    }
+    direct_by_node: dict[int, dict[str, dict[str, float | int]]] = defaultdict(dict)
+    for source in direct_rows:
+        stable_id = int(source["stable_id"])
+        if stable_id not in topology:
+            continue
+        channel_content_id = str(source["channel_content_id"])
+        candidate: dict[str, float | int] = {
+            "source_element_id": int(source["source_element_id"]),
+            "source_category_stable_id": stable_id,
+            "contribution": float(source["contribution"]),
+            **{metric: float(source[metric]) for metric in _METRICS},
+        }
+        current = direct_by_node[stable_id].get(channel_content_id)
+        if _is_better_candidate(candidate, current):
+            direct_by_node[stable_id][channel_content_id] = candidate
+
+    candidates_by_node = dict(direct_by_node)
+    result_rows: list[dict[str, Any]] = []
+    nodes_with_content = 0
+    for stable_id in _postorder(topology):
+        content_by_id = candidates_by_node.pop(stable_id, {})
+        if content_by_id:
+            nodes_with_content += 1
+            result_rows.extend(
+                _build_top_rows(
+                    biz_dt=resolved_dt,
+                    stable_id=stable_id,
+                    content_by_id=content_by_id,
+                )
+            )
+
+        parent_id = topology[stable_id]
+        if parent_id is None or parent_id not in topology:
+            continue
+        parent_content = candidates_by_node.setdefault(parent_id, {})
+        for channel_content_id, candidate in content_by_id.items():
+            current = parent_content.get(channel_content_id)
+            if _is_better_candidate(candidate, current):
+                parent_content[channel_content_id] = candidate
+
+    with get_session() as session:
+        inserted = GlobalCategoryChannelContentRankRepository(session).replace_day(
+            resolved_dt, result_rows
+        )
+
+    result = {
+        "success": True,
+        "biz_dt": resolved_dt,
+        "top_n": _TOP_N,
+        "nodes": len(topology),
+        "nodes_with_content": nodes_with_content,
+        "source_rows": len(direct_rows),
+        "inserted": inserted,
+    }
+    logger.info("Global category channel-content rankings completed: %s", result)
+    return result
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Compute daily category channel-content Top 10 rankings"
+    )
+    parser.add_argument("biz_dt", nargs="?", help="Business date YYYYMMDD")
+    args = parser.parse_args()
+    run_cli(
+        lambda: compute_global_category_channel_content_rankings(args.biz_dt),
+        label="compute_global_category_channel_content_rankings",
+    )

+ 12 - 6
supply_infra/scheduler/jobs/sync_global_v2_snapshot.py

@@ -1,4 +1,5 @@
 """Independent daily ODPS -> MySQL refresh for the global V2 snapshot tables."""
+
 from __future__ import annotations
 
 import argparse
@@ -62,9 +63,9 @@ def sync_global_v2_snapshot(partition_date: str | None = None) -> dict[str, Any]
             elements=elements,
             source_elements=source_elements,
         )
-        inserted["channel_content_data"] = ChannelContentDataRepository(
-            session
-        ).insert_new(channel_content)
+        inserted["channel_content_data"] = ChannelContentDataRepository(session).insert_new(
+            channel_content
+        )
 
     from supply_infra.scheduler.jobs.compute_global_category_content_weights import (
         compute_global_category_content_weights,
@@ -72,15 +73,20 @@ def sync_global_v2_snapshot(partition_date: str | None = None) -> dict[str, Any]
 
     weight_result = compute_global_category_content_weights(resolved_date)
 
+    from supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings import (
+        compute_global_category_channel_content_rankings,
+    )
+
+    ranking_result = compute_global_category_channel_content_rankings(resolved_date)
+
     result = {
         "success": True,
         "partition_date": resolved_date,
         "fetched": fetched,
         "inserted": inserted,
-        "skipped_existing": {
-            table: fetched[table] - inserted[table] for table in fetched
-        },
+        "skipped_existing": {table: fetched[table] - inserted[table] for table in fetched},
         "category_content_weights": weight_result,
+        "category_channel_content_rankings": ranking_result,
     }
     logger.info("Global V2 snapshot sync completed: %s", result)
     return result

+ 65 - 0
tests/api/test_growth_category_tree.py

@@ -1,11 +1,13 @@
 from collections.abc import Generator
 from contextlib import contextmanager
 from types import SimpleNamespace
+from unittest.mock import MagicMock
 
 from sqlalchemy import create_engine
 from sqlalchemy.orm import Session, sessionmaker
 
 from api.auth_middleware import normal_user_can_access
+from api.services import aigc_article_html
 from api.services import growth_category_tree as service
 from api.services.growth_category_tree import _build_growth_tree
 from supply_infra.db.base import Base
@@ -17,6 +19,8 @@ from supply_infra.db.models.global_v2 import GlobalCategoryV2
 
 def test_growth_tree_api_is_available_to_normal_users() -> None:
     assert normal_user_can_access("GET", "/api/growth-category-tree")
+    assert normal_user_can_access("GET", "/api/growth-category-tree/38/channel-contents")
+    assert normal_user_can_access("GET", "/api/growth-channel-content/content-1/view")
 
 
 def test_build_growth_tree_uses_stable_ids_and_daily_scores() -> None:
@@ -132,3 +136,64 @@ def test_build_growth_category_tree_serializes_before_session_closes(monkeypatch
 
     assert payload["biz_dt"] == "20260819"
     assert payload["nodes"][0]["weights"]["avg_read_rate"] == 1.5
+
+
+def test_list_growth_category_channel_contents_serializes_top_ten(monkeypatch) -> None:
+    @contextmanager
+    def get_test_session():
+        yield MagicMock()
+
+    weight_repository = MagicMock()
+    weight_repository.get_latest_completed_biz_dt.return_value = "20260819"
+    rank_repository = MagicMock()
+    rank_repository.list_top.return_value = [
+        SimpleNamespace(
+            rank_no=1,
+            channel_content_id="content-1",
+            source_element_id=101,
+            source_category_stable_id=12,
+            contribution=0.8,
+            metric_value=0.4,
+            weighted_score=1.0,
+            weighted_share=0.25,
+        )
+    ]
+    monkeypatch.setattr(service, "get_session", get_test_session)
+    monkeypatch.setattr(
+        service,
+        "GlobalCategoryContentWeightRepository",
+        lambda _session: weight_repository,
+    )
+    monkeypatch.setattr(
+        service,
+        "GlobalCategoryChannelContentRankRepository",
+        lambda _session: rank_repository,
+    )
+
+    payload = service.list_growth_category_channel_contents(
+        stable_id=38,
+        metric="read_rate",
+    )
+
+    assert payload["biz_dt"] == "20260819"
+    assert payload["total"] == 1
+    assert payload["items"][0]["weighted_score"] == 1.0
+    rank_repository.list_top.assert_called_once_with(
+        biz_dt="20260819",
+        stable_id=38,
+        metric_type="read_rate",
+    )
+
+
+def test_latest_article_html_uses_parameterized_content_id(monkeypatch) -> None:
+    connection = MagicMock()
+    connection.scalar.return_value = b"<html>article</html>"
+    engine = MagicMock()
+    engine.connect.return_value.__enter__.return_value = connection
+    monkeypatch.setattr(aigc_article_html, "_get_aigc_readonly_engine", lambda: engine)
+
+    html = aigc_article_html.get_latest_article_html("content-1")
+
+    assert html == "<html>article</html>"
+    parameters = connection.scalar.call_args.args[1]
+    assert parameters == {"channel_content_id": "content-1"}

+ 106 - 0
tests/supply_infra/scheduler/test_global_category_channel_content_rankings.py

@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings import (
+    _build_top_rows,
+    compute_global_category_channel_content_rankings,
+)
+
+
+def _content(weighted_score: float) -> dict[str, float | int]:
+    return {
+        "source_element_id": int(weighted_score),
+        "source_category_stable_id": 10,
+        "contribution": 1.0,
+        "read_rate": weighted_score,
+        "avg_read_rate": weighted_score * 2,
+        "like_rate": weighted_score / 10,
+    }
+
+
+def test_build_top_rows_keeps_only_ten_per_metric() -> None:
+    content_by_id = {f"content-{index:02d}": _content(float(index)) for index in range(1, 13)}
+
+    rows = _build_top_rows(
+        biz_dt="20260819",
+        stable_id=10,
+        content_by_id=content_by_id,
+    )
+
+    assert len(rows) == 30
+    for metric in ("read_rate", "avg_read_rate", "like_rate"):
+        metric_rows = [row for row in rows if row["metric_type"] == metric]
+        assert [row["rank_no"] for row in metric_rows] == list(range(1, 11))
+        assert metric_rows[0]["channel_content_id"] == "content-12"
+        assert metric_rows[-1]["channel_content_id"] == "content-03"
+
+
+@patch(
+    "supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings."
+    "GlobalCategoryChannelContentRankRepository"
+)
+@patch("supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings.get_session")
+def test_parent_ranking_merges_direct_and_descendant_contents(
+    mock_get_session,
+    repository_cls,
+) -> None:
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repository = repository_cls.return_value
+    repository.load_daily_topology.return_value = [
+        {"stable_id": 1, "parent_stable_id": None, "status": "completed"},
+        {"stable_id": 2, "parent_stable_id": 1, "status": "completed"},
+    ]
+    repository.load_direct_content_metrics.return_value = [
+        {
+            "stable_id": 1,
+            "source_element_id": 11,
+            "channel_content_id": "content-a",
+            "contribution": 1.0,
+            "read_rate": 1.0,
+            "avg_read_rate": 2.0,
+            "like_rate": 0.1,
+        },
+        {
+            "stable_id": 2,
+            "source_element_id": 22,
+            "channel_content_id": "content-a",
+            "contribution": 2.0,
+            "read_rate": 1.0,
+            "avg_read_rate": 2.0,
+            "like_rate": 0.1,
+        },
+        {
+            "stable_id": 2,
+            "source_element_id": 23,
+            "channel_content_id": "content-b",
+            "contribution": 1.0,
+            "read_rate": 2.0,
+            "avg_read_rate": 3.0,
+            "like_rate": 0.2,
+        },
+    ]
+    saved: list[dict] = []
+
+    def replace_day(_biz_dt: str, rows: list[dict]) -> int:
+        saved.extend(deepcopy(rows))
+        return len(rows)
+
+    repository.replace_day.side_effect = replace_day
+
+    result = compute_global_category_channel_content_rankings("20260819")
+
+    root_read_rows = [
+        row for row in saved if row["stable_id"] == 1 and row["metric_type"] == "read_rate"
+    ]
+    assert root_read_rows[0]["channel_content_id"] == "content-a"
+    assert root_read_rows[0]["source_element_id"] == 22
+    assert root_read_rows[0]["source_category_stable_id"] == 2
+    assert root_read_rows[0]["contribution"] == pytest.approx(2.0)
+    assert root_read_rows[0]["weighted_score"] == pytest.approx(2.0)
+    assert root_read_rows[0]["metric_value"] == pytest.approx(1.0)
+    assert result["nodes_with_content"] == 2
+    assert result["source_rows"] == 3

+ 9 - 1
tests/supply_infra/scheduler/test_global_v2_snapshot.py

@@ -63,7 +63,13 @@ def test_snapshot_inserts_new_rows_for_all_three_tables(mock_get_odps, mock_get_
                 "compute_global_category_content_weights",
                 return_value={"success": True, "completed_total": 1},
             ):
-                result = sync_global_v2_snapshot("20260819")
+                with patch(
+                    "supply_infra.scheduler.jobs."
+                    "compute_global_category_channel_content_rankings."
+                    "compute_global_category_channel_content_rankings",
+                    return_value={"success": True, "inserted": 3},
+                ) as compute_rankings:
+                    result = sync_global_v2_snapshot("20260819")
 
     assert result["success"] is True
     odps.fetch_global_categories_v2.assert_called_once_with("20260819")
@@ -77,6 +83,8 @@ def test_snapshot_inserts_new_rows_for_all_three_tables(mock_get_odps, mock_get_
         "channel_content_data": 0,
     }
     repo_cls.return_value.insert_new.assert_called_once()
+    compute_rankings.assert_called_once_with("20260819")
+    assert result["category_channel_content_rankings"]["inserted"] == 3
 
 
 @patch("supply_infra.scheduler.jobs.sync_global_v2_snapshot.get_session")

+ 35 - 1
web/src/api/growthCategory.ts

@@ -1,4 +1,23 @@
-import type { CategoryTreeResponse } from '../types/category'
+import type { CategoryTreeResponse, WeightDimKey } from '../types/category'
+
+export interface GrowthChannelContentRankItem {
+  rank_no: number
+  channel_content_id: string
+  source_element_id: number
+  source_category_stable_id: number
+  contribution: number
+  metric_value: number
+  weighted_score: number
+  weighted_share: number
+}
+
+export interface GrowthChannelContentRankResponse {
+  biz_dt: string | null
+  stable_id: number
+  metric: WeightDimKey
+  total: number
+  items: GrowthChannelContentRankItem[]
+}
 
 export async function fetchGrowthCategoryTree(
   bizDt?: string | null,
@@ -16,3 +35,18 @@ export async function fetchGrowthCategoryTree(
   }
   return response.json()
 }
+
+export async function fetchGrowthCategoryTopContents(
+  stableId: number,
+  bizDt: string,
+  metric: WeightDimKey,
+): Promise<GrowthChannelContentRankResponse> {
+  const params = new URLSearchParams({ biz_dt: bizDt, metric })
+  const response = await fetch(
+    `/api/growth-category-tree/${stableId}/channel-contents?${params.toString()}`,
+  )
+  if (!response.ok) {
+    throw new Error(`加载节点文章 Top 10 失败: ${response.status} ${response.statusText}`)
+  }
+  return response.json()
+}

+ 7 - 0
web/src/components/IcicleHeatTree.vue

@@ -87,6 +87,11 @@ const props = withDefaults(
   },
 )
 
+const emit = defineEmits<{
+  nodeSelected: [node: { id: number; name: string }]
+  tabChange: [tab: HeatTabKey]
+}>()
+
 const canvasRef = ref<HTMLCanvasElement | null>(null)
 const viewportRef = ref<HTMLElement | null>(null)
 const wrapRef = ref<HTMLElement | null>(null)
@@ -470,6 +475,7 @@ function cellText(node: PreparedNode): string {
 function onTabClick(key: HeatTabKey) {
   if (activeTab.value === key) return
   activeTab.value = key
+  emit('tabChange', key)
 }
 
 function resetViewTransform() {
@@ -502,6 +508,7 @@ function selectNode(
 ) {
   if (source === 'canvas') clearDemandListSelection()
   selectedNode.value = node
+  emit('nodeSelected', { id: node.id, name: node.name })
   if (withFocus) {
     focus.value = node
     startDepth.value = node.path.length - 1

+ 331 - 12
web/src/views/GrowthHeatMapView.vue

@@ -1,8 +1,13 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import IcicleHeatTree from '../components/IcicleHeatTree.vue'
-import { fetchGrowthCategoryTree } from '../api/growthCategory'
-import type { CategoryNode, WeightDimMeta } from '../types/category'
+import {
+  fetchGrowthCategoryTopContents,
+  fetchGrowthCategoryTree,
+  type GrowthChannelContentRankItem,
+} from '../api/growthCategory'
+import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
+import type { HeatTabKey } from '../types/heatTree'
 
 const GROWTH_HEAT_TABS: WeightDimMeta[] = [
   { key: 'read_rate', label: '阅读率' },
@@ -14,6 +19,19 @@ const nodes = ref<CategoryNode[]>([])
 const bizDt = ref<string | null>(null)
 const loading = ref(true)
 const error = ref<string | null>(null)
+const selectedNode = ref<{ id: number; name: string } | null>(null)
+const activeMetric = ref<WeightDimKey>('read_rate')
+const rankItems = ref<GrowthChannelContentRankItem[]>([])
+const rankLoading = ref(false)
+const rankError = ref<string | null>(null)
+const rankPanelCollapsed = ref(false)
+let rankRequestId = 0
+
+const activeMetricLabel = computed(
+  () =>
+    GROWTH_HEAT_TABS.find((tab) => tab.key === activeMetric.value)?.label ??
+    activeMetric.value,
+)
 
 onMounted(async () => {
   try {
@@ -26,22 +44,127 @@ onMounted(async () => {
     loading.value = false
   }
 })
+
+async function loadRankings() {
+  if (!selectedNode.value || !bizDt.value) return
+  const requestId = ++rankRequestId
+  rankLoading.value = true
+  rankError.value = null
+  try {
+    const response = await fetchGrowthCategoryTopContents(
+      selectedNode.value.id,
+      bizDt.value,
+      activeMetric.value,
+    )
+    if (requestId === rankRequestId) rankItems.value = response.items ?? []
+  } catch (cause) {
+    if (requestId === rankRequestId) {
+      rankItems.value = []
+      rankError.value = cause instanceof Error ? cause.message : String(cause)
+    }
+  } finally {
+    if (requestId === rankRequestId) rankLoading.value = false
+  }
+}
+
+function onNodeSelected(node: { id: number; name: string }) {
+  selectedNode.value = node
+  void loadRankings()
+}
+
+function onTabChange(tab: HeatTabKey) {
+  if (tab === 'full') return
+  activeMetric.value = tab
+  if (selectedNode.value) void loadRankings()
+}
+
+function formatScore(value: number): string {
+  if (!Number.isFinite(value)) return '—'
+  if (Math.abs(value) >= 100) return value.toFixed(2)
+  if (Math.abs(value) >= 1) return value.toFixed(4)
+  return value.toFixed(6)
+}
+
+function formatContribution(value: number): string {
+  return Number.isFinite(value) ? value.toFixed(2) : '—'
+}
+
+function openArticle(channelContentId: string) {
+  const target = `/api/growth-channel-content/${encodeURIComponent(channelContentId)}/view`
+  window.open(target, '_blank', 'noopener,noreferrer')
+}
 </script>
 
 <template>
   <div class="view">
     <div v-if="loading" class="state">正在加载增长分类树与权重…</div>
     <div v-else-if="error" class="state error">{{ error }}</div>
-    <IcicleHeatTree
+    <div
       v-else
-      :nodes="nodes"
-      :biz-dt="bizDt"
-      title="增长全局热度地图"
-      subtitle="global_category_v2 分类树 · 横向为层级,纵向为分支规模"
-      :heat-tabs="GROWTH_HEAT_TABS"
-      initial-tab="read_rate"
-      heat-only
-    />
+      class="content-layout"
+      :class="{ 'rank-panel-collapsed': rankPanelCollapsed }"
+    >
+      <IcicleHeatTree
+        class="heat-map"
+        :nodes="nodes"
+        :biz-dt="bizDt"
+        title="增长全局热度地图"
+        subtitle="global_category_v2 分类树 · 横向为层级,纵向为分支规模"
+        :heat-tabs="GROWTH_HEAT_TABS"
+        initial-tab="read_rate"
+        heat-only
+        @node-selected="onNodeSelected"
+        @tab-change="onTabChange"
+      />
+
+      <aside class="rank-panel" :class="{ collapsed: rankPanelCollapsed }">
+        <header class="rank-header" :class="{ collapsed: rankPanelCollapsed }">
+          <button
+            type="button"
+            class="rank-collapse-button"
+            :title="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
+            :aria-label="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
+            :aria-expanded="!rankPanelCollapsed"
+            @click="rankPanelCollapsed = !rankPanelCollapsed"
+          >
+            {{ rankPanelCollapsed ? '‹' : '›' }}
+          </button>
+          <div v-if="!rankPanelCollapsed" class="rank-header-copy">
+            <span class="rank-eyebrow">CHANNEL CONTENT TOP 10</span>
+            <h2>{{ selectedNode?.name || '请选择分类节点' }}</h2>
+            <p v-if="selectedNode">
+              {{ activeMetricLabel }}贡献权重 · stable_id {{ selectedNode.id }}
+            </p>
+            <p v-else>点击左侧热力图节点查看当前维度的文章榜单</p>
+          </div>
+        </header>
+
+        <template v-if="!rankPanelCollapsed">
+          <div v-if="rankLoading" class="rank-state">正在加载 Top 10…</div>
+          <div v-else-if="rankError" class="rank-state error">{{ rankError }}</div>
+          <div v-else-if="!selectedNode" class="rank-state">尚未选择节点</div>
+          <div v-else-if="!rankItems.length" class="rank-state">该节点当前维度暂无文章</div>
+          <ol v-else class="rank-list">
+            <li v-for="item in rankItems" :key="item.channel_content_id">
+              <button type="button" @click="openArticle(item.channel_content_id)">
+                <span class="rank-no">{{ item.rank_no }}</span>
+                <span class="rank-main">
+                  <code>{{ item.channel_content_id }}</code>
+                  <small>
+                    source_element {{ item.source_element_id }}
+                    · contribution {{ formatContribution(item.contribution) }}
+                  </small>
+                </span>
+                <span class="rank-score">
+                  <strong>{{ formatScore(item.weighted_score) }}</strong>
+                  <small>{{ activeMetricLabel }}贡献权重分</small>
+                </span>
+              </button>
+            </li>
+          </ol>
+        </template>
+      </aside>
+    </div>
   </div>
 </template>
 
@@ -52,6 +175,202 @@ onMounted(async () => {
   padding-bottom: 8px;
 }
 
+.content-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 360px;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+}
+
+.content-layout.rank-panel-collapsed {
+  grid-template-columns: minmax(0, 1fr) 44px;
+}
+
+.heat-map {
+  min-width: 0;
+}
+
+.rank-panel {
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.rank-panel.collapsed {
+  background: #f8fafc;
+}
+
+.rank-header {
+  flex-shrink: 0;
+  padding: 16px;
+  border-bottom: 1px solid #e8edf2;
+  background: #f8fafc;
+}
+
+.rank-header.collapsed {
+  display: flex;
+  height: 100%;
+  justify-content: center;
+  padding: 8px 5px;
+  border-bottom: 0;
+}
+
+.rank-header-copy {
+  min-width: 0;
+}
+
+.rank-collapse-button {
+  float: right;
+  width: 28px;
+  height: 28px;
+  margin: 0 0 6px 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 7px;
+  background: #fff;
+  color: #475569;
+  font-size: 18px;
+  line-height: 1;
+  cursor: pointer;
+}
+
+.rank-collapse-button:hover {
+  border-color: #818cf8;
+  color: #4338ca;
+}
+
+.rank-header.collapsed .rank-collapse-button {
+  float: none;
+  margin: 0;
+}
+
+.rank-eyebrow {
+  color: #6366f1;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+}
+
+.rank-header h2 {
+  margin: 7px 0 4px;
+  color: #0f172a;
+  font-size: 17px;
+}
+
+.rank-header p {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.rank-state {
+  display: grid;
+  flex: 1;
+  place-items: center;
+  padding: 24px;
+  color: #94a3b8;
+  font-size: 13px;
+  text-align: center;
+}
+
+.rank-state.error {
+  color: #b91c1c;
+}
+
+.rank-list {
+  margin: 0;
+  padding: 8px;
+  overflow-y: auto;
+  list-style: none;
+}
+
+.rank-list li + li {
+  border-top: 1px solid #eef2f7;
+}
+
+.rank-list button {
+  display: grid;
+  width: 100%;
+  padding: 12px 8px;
+  grid-template-columns: 28px minmax(0, 1fr) auto;
+  gap: 8px;
+  align-items: center;
+  border: 0;
+  border-radius: 7px;
+  background: transparent;
+  color: inherit;
+  cursor: pointer;
+  text-align: left;
+}
+
+.rank-list button:hover {
+  background: #f1f5f9;
+}
+
+.rank-no {
+  display: grid;
+  width: 24px;
+  height: 24px;
+  place-items: center;
+  border-radius: 7px;
+  background: #e0e7ff;
+  color: #4338ca;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.rank-main,
+.rank-score {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.rank-main code {
+  overflow: hidden;
+  color: #334155;
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rank-main small,
+.rank-score small {
+  color: #94a3b8;
+  font-size: 10px;
+}
+
+.rank-score {
+  align-items: flex-end;
+}
+
+.rank-score strong {
+  color: #dc2626;
+  font-size: 13px;
+  font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 1100px) {
+  .view {
+    height: auto;
+  }
+
+  .content-layout {
+    grid-template-columns: 1fr;
+    grid-template-rows: 760px minmax(360px, auto);
+  }
+
+  .rank-panel {
+    min-height: 360px;
+  }
+}
+
 .state {
   padding: 64px 24px;
   color: #64748b;