Explorar el Código

增加需求汇总页面

xueyiming hace 2 días
padre
commit
a4a0559795

+ 10 - 0
.env.example

@@ -51,3 +51,13 @@ STRATEGY_STAGING_HOURLY_GENERATE_ENABLED=true
 STRATEGY_STAGING_HOURLY_GENERATE_START_HOUR=3
 STRATEGY_STAGING_HOURLY_GENERATE_END_HOUR=23
 STRATEGY_STAGING_HOURLY_GENERATE_MINUTE=0
+SUBSTANCE_ELEMENT_BASE_TABLE=substance_element_base
+SUBSTANCE_ELEMENT_EFFECT_TABLE=substance_element_effect_di
+SUBSTANCE_ELEMENT_DAILY_SYNC_ENABLED=true
+SUBSTANCE_ELEMENT_DAILY_SYNC_HOUR=9
+SUBSTANCE_ELEMENT_DAILY_SYNC_MINUTE=30
+VERTICAL_CATEGORY_BASE_TABLE=vertical_category_base
+VERTICAL_CATEGORY_EFFECT_TABLE=vertical_category_effect_di
+VERTICAL_CATEGORY_DAILY_SYNC_ENABLED=true
+VERTICAL_CATEGORY_DAILY_SYNC_HOUR=9
+VERTICAL_CATEGORY_DAILY_SYNC_MINUTE=30

+ 2 - 0
.gitignore

@@ -44,3 +44,5 @@ dist/
 build/
 coverage/
 /crawler/
+/scripts/
+/output/

+ 14 - 0
app/api/routes.py

@@ -23,6 +23,7 @@ from app.services.element_search_service import (
     query_same_period_last_year_lunar_element_demands,
     query_video_decode_url2_for_today,
 )
+from app.services.vertical_category_tree_service import query_vertical_category_tree
 from app.utils.excel_export import build_content_disposition, rows_to_excel_bytes
 
 router = APIRouter()
@@ -527,6 +528,19 @@ async def export_hot_content_demand_exports_api(
     )
 
 
+@router.get("/vertical-category/tree")
+async def get_vertical_category_tree(
+    dt: str | None = Query(
+        default=None,
+        description="效果分区日期: yyyymmdd 或 yyyy-mm-dd;未传则取最新分区",
+    ),
+) -> dict[str, object]:
+    try:
+        return query_vertical_category_tree(dt=dt)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
 @router.get("/demand-pool/strategies")
 async def get_demand_pool_strategies(
     start_dt: str | None = Query(default=None, description="开始日期: yyyymmdd 或 yyyy-mm-dd"),

+ 10 - 0
app/core/config.py

@@ -59,6 +59,16 @@ class Settings(BaseSettings):
     strategy_staging_hourly_generate_start_hour: int = 3
     strategy_staging_hourly_generate_end_hour: int = 23
     strategy_staging_hourly_generate_minute: int = 0
+    substance_element_base_table: str = "substance_element_base"
+    substance_element_effect_table: str = "substance_element_effect_di"
+    substance_element_daily_sync_enabled: bool = True
+    substance_element_daily_sync_hour: int = 9
+    substance_element_daily_sync_minute: int = 30
+    vertical_category_base_table: str = "vertical_category_base"
+    vertical_category_effect_table: str = "vertical_category_effect_di"
+    vertical_category_daily_sync_enabled: bool = True
+    vertical_category_daily_sync_hour: int = 9
+    vertical_category_daily_sync_minute: int = 30
 
     model_config = SettingsConfigDict(
         env_file=".env",

+ 34 - 0
app/scheduler/jobs.py

@@ -4,6 +4,8 @@ from zoneinfo import ZoneInfo
 from app.services.demand_pool_strategy_daily_alert import run_daily_strategy_alert
 from app.services.strategy_generate_service import run_strategy_generation
 from app.sync.demand_pool_sync import run_full_sync, run_today_incremental_sync
+from app.sync.substance_element_sync import sync_substance_elements
+from app.sync.vertical_category_sync import sync_vertical_categories
 
 
 def heartbeat_job() -> None:
@@ -33,6 +35,38 @@ def demand_pool_daily_strategy_alert_job(partition_dt: str | None = None) -> Non
         raise
 
 
+def substance_element_daily_sync_job(partition_dt: str | None = None) -> None:
+    print("[scheduler] start daily sync for substance elements")
+    try:
+        result = sync_substance_elements(partition_dt)
+        print(
+            "[scheduler] substance element sync done: "
+            f"partition_dt={result['partition_dt']}, "
+            f"base_fetched={result['base_fetched']}, "
+            f"base_inserted={result['base_inserted']}, "
+            f"effect_inserted={result['effect_inserted']}"
+        )
+    except Exception as exc:
+        print(f"[scheduler] substance element sync failed: {exc}")
+        raise
+
+
+def vertical_category_daily_sync_job(partition_dt: str | None = None) -> None:
+    print("[scheduler] start daily sync for vertical categories")
+    try:
+        result = sync_vertical_categories(partition_dt)
+        print(
+            "[scheduler] vertical category sync done: "
+            f"partition_dt={result['partition_dt']}, "
+            f"base_fetched={result['base_fetched']}, "
+            f"base_inserted={result['base_inserted']}, "
+            f"effect_inserted={result['effect_inserted']}"
+        )
+    except Exception as exc:
+        print(f"[scheduler] vertical category sync failed: {exc}")
+        raise
+
+
 def strategy_staging_hourly_generate_job(batch_date: str | None = None) -> None:
     print("[scheduler] start hourly strategy generation for strategy_staging")
     try:

+ 28 - 0
app/scheduler/manager.py

@@ -12,6 +12,8 @@ from app.scheduler.jobs import (
     demand_pool_today_incremental_sync_job,
     heartbeat_job,
     strategy_staging_hourly_generate_job,
+    substance_element_daily_sync_job,
+    vertical_category_daily_sync_job,
 )
 
 # 与 scheduler 一致。APScheduler 3.x 在 add_job 里传入 CronTrigger 实例时,若未显式指定
@@ -67,6 +69,32 @@ def setup_jobs() -> None:
             max_instances=1,
             coalesce=True,
         )
+    if settings.substance_element_daily_sync_enabled:
+        scheduler.add_job(
+            substance_element_daily_sync_job,
+            trigger=CronTrigger(
+                hour=settings.substance_element_daily_sync_hour,
+                minute=settings.substance_element_daily_sync_minute,
+                timezone=_CRON_TZ,
+            ),
+            id="substance_element_daily_sync_job",
+            replace_existing=True,
+            max_instances=1,
+            coalesce=True,
+        )
+    if settings.vertical_category_daily_sync_enabled:
+        scheduler.add_job(
+            vertical_category_daily_sync_job,
+            trigger=CronTrigger(
+                hour=settings.vertical_category_daily_sync_hour,
+                minute=settings.vertical_category_daily_sync_minute,
+                timezone=_CRON_TZ,
+            ),
+            id="vertical_category_daily_sync_job",
+            replace_existing=True,
+            max_instances=1,
+            coalesce=True,
+        )
 
 
 def start_scheduler() -> None:

+ 180 - 0
app/services/vertical_category_tree_service.py

@@ -0,0 +1,180 @@
+"""垂直领域分类树:查询分类/元素基础信息与效果得分。"""
+
+import re
+
+from sqlalchemy import text
+
+from app.core.config import settings
+from app.db.mysql import SessionLocal
+
+IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+DATE_RE = re.compile(r"^\d{8}$")
+
+
+def _safe_identifier(name: str) -> str:
+    if not IDENTIFIER_RE.match(name):
+        raise ValueError(f"invalid sql identifier: {name}")
+    return name
+
+
+def _normalize_date(date_value: str | None) -> str | None:
+    if not date_value:
+        return None
+    normalized = date_value.replace("-", "").strip()
+    if not normalized:
+        return None
+    if not DATE_RE.match(normalized):
+        raise ValueError("date must be yyyymmdd or yyyy-mm-dd")
+    return normalized
+
+
+def _resolve_partition_dt(dt: str | None) -> str:
+    normalized = _normalize_date(dt)
+    if normalized:
+        return normalized
+
+    category_effect_table = _safe_identifier(settings.vertical_category_effect_table)
+    with SessionLocal() as session:
+        row = session.execute(
+            text(f"SELECT MAX(dt) FROM {category_effect_table}")
+        ).first()
+    latest = str(row[0] or "").strip() if row else ""
+    if not latest:
+        raise ValueError("暂无效果数据分区,请先同步垂直领域分类数据")
+    return latest
+
+
+def query_available_dates() -> list[str]:
+    category_effect_table = _safe_identifier(settings.vertical_category_effect_table)
+    with SessionLocal() as session:
+        rows = session.execute(
+            text(
+                f"""
+                SELECT DISTINCT dt
+                FROM {category_effect_table}
+                ORDER BY dt DESC
+                LIMIT 366
+                """
+            )
+        ).all()
+    return [str(row[0]) for row in rows if row[0]]
+
+
+def query_vertical_category_tree(dt: str | None = None) -> dict[str, object]:
+    partition_dt = _resolve_partition_dt(dt)
+
+    category_base_table = _safe_identifier(settings.vertical_category_base_table)
+    category_effect_table = _safe_identifier(settings.vertical_category_effect_table)
+    element_base_table = _safe_identifier(settings.substance_element_base_table)
+    element_effect_table = _safe_identifier(settings.substance_element_effect_table)
+
+    category_sql = text(
+        f"""
+        SELECT
+            b.category_id,
+            b.parent_stable_id,
+            b.category_name,
+            b.category_level,
+            b.dimension,
+            b.classified_as,
+            e.vid_count,
+            e.rov_score,
+            e.str_score,
+            e.ros_score
+        FROM {category_base_table} b
+        LEFT JOIN {category_effect_table} e
+            ON b.category_id = e.category_id
+            AND e.dt = :dt
+        ORDER BY b.category_level ASC, b.category_id ASC
+        """
+    )
+
+    element_sql = text(
+        f"""
+        SELECT
+            b.element_id,
+            b.stable_id,
+            b.element_name,
+            b.dimension,
+            b.classified_as,
+            e.vid_count,
+            e.rov_score,
+            e.str_score,
+            e.ros_score
+        FROM {element_base_table} b
+        LEFT JOIN {element_effect_table} e
+            ON b.element_id = e.element_id
+            AND e.dt = :dt
+        ORDER BY b.stable_id ASC, b.element_id ASC
+        """
+    )
+
+    with SessionLocal() as session:
+        category_rows = session.execute(category_sql, {"dt": partition_dt}).mappings().all()
+        element_rows = session.execute(element_sql, {"dt": partition_dt}).mappings().all()
+
+    categories: list[dict[str, object]] = []
+    child_category_ids: set[str] = set()
+    category_rov_values: list[float] = []
+
+    for row in category_rows:
+        category_id = str(row["category_id"] or "").strip()
+        parent_id = str(row["parent_stable_id"] or "").strip() or None
+        if parent_id:
+            child_category_ids.add(parent_id)
+        rov_score = row["rov_score"]
+        if rov_score is not None and float(rov_score) != 0.0:
+            category_rov_values.append(float(rov_score))
+        categories.append(
+            {
+                "category_id": category_id,
+                "parent_stable_id": parent_id,
+                "category_name": row["category_name"],
+                "category_level": row["category_level"],
+                "dimension": row["dimension"],
+                "classified_as": row["classified_as"],
+                "vid_count": row["vid_count"],
+                "rov_score": float(rov_score) if rov_score is not None else None,
+                "str_score": float(row["str_score"]) if row["str_score"] is not None else None,
+                "ros_score": float(row["ros_score"]) if row["ros_score"] is not None else None,
+            }
+        )
+
+    for item in categories:
+        item["is_leaf"] = item["category_id"] not in child_category_ids
+
+    elements: list[dict[str, object]] = []
+    element_rov_values: list[float] = []
+    for row in element_rows:
+        rov_score = row["rov_score"]
+        if rov_score is not None and float(rov_score) != 0.0:
+            element_rov_values.append(float(rov_score))
+        elements.append(
+            {
+                "element_id": str(row["element_id"] or "").strip(),
+                "stable_id": str(row["stable_id"] or "").strip() or None,
+                "element_name": row["element_name"],
+                "dimension": row["dimension"],
+                "classified_as": row["classified_as"],
+                "vid_count": row["vid_count"],
+                "rov_score": float(rov_score) if rov_score is not None else None,
+                "str_score": float(row["str_score"]) if row["str_score"] is not None else None,
+                "ros_score": float(row["ros_score"]) if row["ros_score"] is not None else None,
+            }
+        )
+
+    category_min_rov = min(category_rov_values) if category_rov_values else 0.0
+    category_max_rov = max(category_rov_values) if category_rov_values else 0.0
+    element_min_rov = min(element_rov_values) if element_rov_values else 0.0
+    element_max_rov = max(element_rov_values) if element_rov_values else 0.0
+
+    return {
+        "dt": partition_dt,
+        "available_dates": query_available_dates(),
+        "category_min_rov_score": category_min_rov,
+        "category_max_rov_score": category_max_rov,
+        "element_min_rov_score": element_min_rov,
+        "element_max_rov_score": element_max_rov,
+        "categories": categories,
+        "elements": elements,
+    }

+ 467 - 0
app/sync/substance_element_sync.py

@@ -0,0 +1,467 @@
+"""垂直领域实质元素:从 ODPS 同步基本元素(增量)与效果数据(按日全量)。"""
+
+import json
+import re
+from datetime import datetime, timedelta
+from decimal import Decimal
+from zoneinfo import ZoneInfo
+
+from sqlalchemy import bindparam, text
+
+from app.core.config import settings
+from app.db.mysql import SessionLocal
+from app.odps.client import get_odps_client
+
+IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+BATCH_SIZE = 500
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+
+_T_ELEMENT_CTE = """
+WITH t_element AS
+(
+    SELECT  t1.post_id AS vid
+            ,t1.global_element_id AS element_id
+            ,t1.element_name AS 元素名称
+            ,t1.element_type AS 维度
+            ,t4.classified_as AS 归类
+            ,t4.name AS 分类名称
+            ,t4.stable_id AS stable_id
+            ,t4.description AS 分类说明
+            ,t4.level AS 分类层级
+            ,t4.path AS 分类路径
+            ,t1.element_type AS 元素维度
+            ,t6.topic_point_type AS 点类型
+            ,t2.contribution AS 贡献度
+            ,t2.share_will AS 分享意愿度
+            ,t2.consume_will AS 消费意愿度
+            ,t2.click_will AS 点击意愿度
+    FROM    loghubods.element_classification_mapping t1
+    LEFT JOIN loghubods.dwd_post_word_contribution_di t2
+    ON      t1.post_id = t2.post_id
+    AND     t1.element_name = t2.word
+    AND     t2.dt = '{bizdate}'
+    LEFT JOIN loghubods.global_element t3
+    ON      t1.global_element_id = t3.id
+    AND     t3.dt = '{bizdate}'
+    LEFT JOIN loghubods.global_category t4
+    ON      t1.global_category_stable_id = t4.stable_id
+    AND     t4.dt = '{bizdate}'
+    LEFT JOIN loghubods.post_decode_topic_point_element t5
+    ON      t1.source_element_id = t5.id
+    AND     t1.post_id = t5.post_id
+    AND     t5.dt = '{bizdate}'
+    LEFT JOIN loghubods.post_decode_topic_point t6
+    ON      t5.topic_point_id = t6.id
+    AND     t6.dt = '{bizdate}'
+    WHERE   t1.dt = '{bizdate}'
+    AND     LENGTH(CAST(t1.post_id AS STRING)) <= 10
+    AND     t3.retired_at_execution_id IS NULL
+    AND     t4.retired_at_execution_id IS NULL
+    AND     t5.id IS NOT NULL
+    AND     t1.element_type = '实质'
+    AND     t4.classified_as = '垂直领域细分'
+)
+"""
+
+_T_ELEMENT_BASE_CTE = """
+WITH t_element AS
+(
+    SELECT  t1.global_element_id AS element_id
+            ,t1.element_name AS 元素名称
+            ,t1.element_type AS 维度
+            ,t4.classified_as AS 归类
+            ,t4.stable_id AS stable_id
+    FROM    loghubods.element_classification_mapping t1
+    LEFT JOIN loghubods.global_element t3
+    ON      t1.global_element_id = t3.id
+    AND     t3.dt = '{bizdate}'
+    LEFT JOIN loghubods.global_category t4
+    ON      t1.global_category_stable_id = t4.stable_id
+    AND     t4.dt = '{bizdate}'
+    LEFT JOIN loghubods.post_decode_topic_point_element t5
+    ON      t1.source_element_id = t5.id
+    AND     t1.post_id = t5.post_id
+    AND     t5.dt = '{bizdate}'
+    WHERE   t1.dt = '{bizdate}'
+    AND     LENGTH(CAST(t1.post_id AS STRING)) <= 10
+    AND     t3.retired_at_execution_id IS NULL
+    AND     t4.retired_at_execution_id IS NULL
+    AND     t5.id IS NOT NULL
+    AND     t1.element_type = '实质'
+    AND     t4.classified_as = '垂直领域细分'
+)
+"""
+
+
+def _safe_identifier(name: str) -> str:
+    if not IDENTIFIER_RE.match(name):
+        raise ValueError(f"invalid sql identifier: {name}")
+    return name
+
+
+def _serialize_vid_list(value: object) -> str | None:
+    if value is None:
+        return None
+    if isinstance(value, list):
+        return json.dumps(value, ensure_ascii=False)
+    return str(value)
+
+
+def _normalize_scalar(value: object) -> object:
+    if isinstance(value, Decimal):
+        return float(value)
+    return value
+
+
+def _normalize_element_id(value: object) -> str:
+    text_value = str(value or "").strip()
+    if not text_value:
+        raise ValueError("element_id 不能为空")
+    return text_value
+
+
+def _yesterday_partition_dt(reference: datetime | None = None) -> str:
+    now = reference or datetime.now(SHANGHAI_TZ)
+    return (now.date() - timedelta(days=1)).strftime("%Y%m%d")
+
+
+def _build_sync_sql(bizdate: str) -> str:
+    return (
+        _T_ELEMENT_CTE.format(bizdate=bizdate)
+        + """
+SELECT  CAST(t1.element_id AS STRING) AS element_id
+        ,t1.元素名称 AS element_name
+        ,t1.维度 AS dimension
+        ,t1.归类 AS classified_as
+        ,CAST(t1.stable_id AS STRING) AS stable_id
+        ,COUNT(DISTINCT t1.vid) AS vid_count
+        ,COLLECT_SET(t1.vid) AS vid_list
+        ,COALESCE(ROUND(
+            SUM(t2.分发回流uv * CASE WHEN t1.贡献度 >= 0.8 THEN t1.贡献度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发曝光pv), 0)
+        , 4), 0) AS rov_score
+        ,COALESCE(ROUND(
+            SUM(t2.分发分享pv * CASE WHEN t1.分享意愿度 >= 0.8 THEN t1.分享意愿度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发曝光pv), 0)
+        , 4), 0) AS str_score
+        ,COALESCE(ROUND(
+            SUM(t2.分发回流uv * CASE WHEN t1.点击意愿度 >= 0.8 THEN t1.点击意愿度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发分享pv), 0)
+        , 4), 0) AS ros_score
+FROM    t_element t1
+LEFT JOIN loghubods.video_multi_strategy_effect t2
+ON      t1.vid = t2.vid
+AND     t2.dt = '{bizdate}'
+AND     t2.strategy = '当天'
+GROUP BY t1.element_id
+         ,t1.元素名称
+         ,t1.维度
+         ,t1.归类
+         ,t1.stable_id
+"""
+    ).format(bizdate=bizdate)
+
+
+def _fetch_from_odps(bizdate: str) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
+    sql = _build_sync_sql(bizdate)
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+
+    base_rows: dict[str, dict[str, object]] = {}
+    effect_rows: list[dict[str, object]] = []
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            element_id = _normalize_element_id(record["element_id"])
+            base_rows[element_id] = {
+                "element_id": element_id,
+                "stable_id": str(record["stable_id"] or "").strip() or None,
+                "element_name": record["element_name"],
+                "dimension": record["dimension"],
+                "classified_as": record["classified_as"],
+            }
+            effect_rows.append(
+                {
+                    "element_id": element_id,
+                    "vid_count": record["vid_count"],
+                    "vid_list": _serialize_vid_list(record["vid_list"]),
+                    "rov_score": _normalize_scalar(record["rov_score"]),
+                    "str_score": _normalize_scalar(record["str_score"]),
+                    "ros_score": _normalize_scalar(record["ros_score"]),
+                    "dt": bizdate,
+                }
+            )
+    return list(base_rows.values()), effect_rows
+
+
+def _build_base_fields_sql(bizdate: str) -> str:
+    return (
+        _T_ELEMENT_BASE_CTE.format(bizdate=bizdate)
+        + """
+SELECT  DISTINCT CAST(t1.element_id AS STRING) AS element_id
+        ,CAST(t1.stable_id AS STRING) AS stable_id
+        ,t1.元素名称 AS element_name
+        ,t1.维度 AS dimension
+        ,t1.归类 AS classified_as
+FROM    t_element t1
+"""
+    )
+
+
+def _fetch_base_fields_from_odps(bizdate: str) -> list[dict[str, object]]:
+    print(f"[update] querying ODPS base fields, dt={bizdate} ...")
+    sql = _build_base_fields_sql(bizdate)
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+
+    rows: dict[str, dict[str, object]] = {}
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            element_id = _normalize_element_id(record["element_id"])
+            rows[element_id] = {
+                "element_id": element_id,
+                "stable_id": str(record["stable_id"] or "").strip() or None,
+                "element_name": record["element_name"],
+                "dimension": record["dimension"],
+                "classified_as": record["classified_as"],
+            }
+    print(f"[update] ODPS done, fetched {len(rows)} rows")
+    return list(rows.values())
+
+
+def _update_base_stable_id(rows: list[dict[str, object]]) -> int:
+    if not rows:
+        return 0
+
+    base_table = _safe_identifier(settings.substance_element_base_table)
+    update_sql = text(
+        f"""
+        UPDATE {base_table}
+        SET stable_id = :stable_id
+        WHERE element_id = :element_id
+          AND NOT (stable_id <=> :stable_id)
+        """
+    )
+
+    updated = 0
+    with SessionLocal() as session:
+        for start in range(0, len(rows), BATCH_SIZE):
+            batch = rows[start : start + BATCH_SIZE]
+            result = session.execute(update_sql, batch)
+            updated += int(result.rowcount or 0)
+        session.commit()
+    return updated
+
+
+def truncate_substance_element_base() -> None:
+    base_table = _safe_identifier(settings.substance_element_base_table)
+    with SessionLocal() as session:
+        session.execute(text(f"TRUNCATE TABLE {base_table}"))
+        session.commit()
+    print(f"[sync] truncated {base_table}")
+
+
+def sync_substance_element_base_only(partition_dt: str | None = None) -> dict[str, int | str]:
+    """仅从 ODPS 全量写入 substance_element_base,不重写 effect_di 表。"""
+    _ensure_tables()
+    bizdate = partition_dt or _yesterday_partition_dt()
+    base_rows = _fetch_base_fields_from_odps(bizdate)
+    print(f"[sync] inserting {len(base_rows)} rows into base table ...")
+    inserted_count = _insert_base_elements(base_rows)
+    return {
+        "partition_dt": bizdate,
+        "base_fetched": len(base_rows),
+        "base_inserted": inserted_count,
+    }
+
+
+def update_substance_element_base_fields(partition_dt: str | None = None) -> dict[str, int | str]:
+    """仅回填 substance_element_base.stable_id,不重写 effect_di 表。"""
+    bizdate = partition_dt or _yesterday_partition_dt()
+    base_rows = _fetch_base_fields_from_odps(bizdate)
+    print(f"[update] matching existing rows in MySQL ...")
+    existing_ids = _load_existing_element_ids([str(row["element_id"]) for row in base_rows])
+    update_rows = [row for row in base_rows if str(row["element_id"]) in existing_ids]
+    print(f"[update] updating stable_id for {len(update_rows)} rows ...")
+    updated_count = _update_base_stable_id(update_rows)
+
+    return {
+        "partition_dt": bizdate,
+        "base_fetched": len(base_rows),
+        "base_matched": len(update_rows),
+        "base_updated": updated_count,
+    }
+
+
+def _ensure_tables() -> None:
+    base_table = _safe_identifier(settings.substance_element_base_table)
+    effect_table = _safe_identifier(settings.substance_element_effect_table)
+    create_base_sql = f"""
+    CREATE TABLE IF NOT EXISTS {base_table}
+    (
+        id BIGINT AUTO_INCREMENT COMMENT '自增id' PRIMARY KEY,
+        element_id VARCHAR(64) NOT NULL COMMENT '元素id',
+        stable_id VARCHAR(64) NULL COMMENT '分类stable_id',
+        element_name VARCHAR(256) NULL COMMENT '元素名称',
+        dimension VARCHAR(32) NULL COMMENT '元素维度',
+        classified_as VARCHAR(64) NULL COMMENT '归类',
+        create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+        update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+        UNIQUE KEY uniq_element_id (element_id)
+    )
+    """
+    create_effect_sql = f"""
+    CREATE TABLE IF NOT EXISTS {effect_table}
+    (
+        id BIGINT AUTO_INCREMENT COMMENT '自增id' PRIMARY KEY,
+        element_id VARCHAR(64) NOT NULL COMMENT '元素id',
+        vid_count BIGINT NULL COMMENT '视频数量',
+        vid_list LONGTEXT NULL COMMENT '视频列表',
+        rov_score DOUBLE NULL COMMENT 'ROV得分',
+        str_score DOUBLE NULL COMMENT 'STR得分',
+        ros_score DOUBLE NULL COMMENT 'ROS得分',
+        dt VARCHAR(32) NOT NULL COMMENT '分区日期',
+        create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+        update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+        UNIQUE KEY uniq_element_dt (element_id, dt)
+    )
+    """
+    alter_base_sql = f"""
+    ALTER TABLE {base_table}
+    ADD COLUMN stable_id VARCHAR(64) NULL COMMENT '分类stable_id' AFTER element_id
+    """
+    alter_effect_sql = f"""
+    ALTER TABLE {effect_table}
+    MODIFY COLUMN vid_list LONGTEXT NULL COMMENT '视频列表'
+    """
+    check_column_sql = text(
+        """
+        SELECT COUNT(*)
+        FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = :table_name
+          AND COLUMN_NAME = :column_name
+        """
+    )
+    with SessionLocal() as session:
+        session.execute(text(create_base_sql))
+        session.execute(text(create_effect_sql))
+        if session.execute(
+            check_column_sql,
+            {"table_name": settings.substance_element_base_table, "column_name": "stable_id"},
+        ).scalar() == 0:
+            session.execute(text(alter_base_sql))
+        session.execute(text(alter_effect_sql))
+        session.commit()
+
+
+def _load_existing_element_ids(element_ids: list[str]) -> set[str]:
+    if not element_ids:
+        return set()
+
+    base_table = _safe_identifier(settings.substance_element_base_table)
+    existing: set[str] = set()
+    query_sql = (
+        text(
+            f"""
+        SELECT element_id
+        FROM {base_table}
+        WHERE element_id IN :element_ids
+        """
+        )
+        .bindparams(bindparam("element_ids", expanding=True))
+    )
+
+    with SessionLocal() as session:
+        for start in range(0, len(element_ids), BATCH_SIZE):
+            batch = element_ids[start : start + BATCH_SIZE]
+            rows = session.execute(query_sql, {"element_ids": tuple(batch)}).all()
+            existing.update(str(row[0]) for row in rows)
+    return existing
+
+
+def _insert_base_elements(rows: list[dict[str, object]]) -> int:
+    if not rows:
+        return 0
+
+    base_table = _safe_identifier(settings.substance_element_base_table)
+    insert_sql = text(
+        f"""
+        INSERT INTO {base_table}
+        (
+            element_id,
+            stable_id,
+            element_name,
+            dimension,
+            classified_as
+        )
+        VALUES
+        (
+            :element_id,
+            :stable_id,
+            :element_name,
+            :dimension,
+            :classified_as
+        )
+        """
+    )
+
+    with SessionLocal() as session:
+        for start in range(0, len(rows), BATCH_SIZE):
+            session.execute(insert_sql, rows[start : start + BATCH_SIZE])
+        session.commit()
+    return len(rows)
+
+
+def _replace_effect_partition(partition_dt: str, rows: list[dict[str, object]]) -> int:
+    effect_table = _safe_identifier(settings.substance_element_effect_table)
+    delete_sql = text(f"DELETE FROM {effect_table} WHERE dt = :dt")
+    insert_sql = text(
+        f"""
+        INSERT INTO {effect_table}
+        (
+            element_id,
+            vid_count,
+            vid_list,
+            rov_score,
+            str_score,
+            ros_score,
+            dt
+        )
+        VALUES
+        (
+            :element_id,
+            :vid_count,
+            :vid_list,
+            :rov_score,
+            :str_score,
+            :ros_score,
+            :dt
+        )
+        """
+    )
+
+    with SessionLocal() as session:
+        session.execute(delete_sql, {"dt": partition_dt})
+        for start in range(0, len(rows), BATCH_SIZE):
+            session.execute(insert_sql, rows[start : start + BATCH_SIZE])
+        session.commit()
+    return len(rows)
+
+
+def sync_substance_elements(partition_dt: str | None = None) -> dict[str, int | str]:
+    """同步指定分区(默认上海时区昨天)的基本元素增量与效果全量。"""
+    _ensure_tables()
+    bizdate = partition_dt or _yesterday_partition_dt()
+
+    base_rows, effect_rows = _fetch_from_odps(bizdate)
+    existing_ids = _load_existing_element_ids([str(row["element_id"]) for row in base_rows])
+    incremental_rows = [row for row in base_rows if str(row["element_id"]) not in existing_ids]
+    inserted_base_count = _insert_base_elements(incremental_rows)
+    inserted_effect_count = _replace_effect_partition(bizdate, effect_rows)
+
+    return {
+        "partition_dt": bizdate,
+        "base_fetched": len(base_rows),
+        "base_inserted": inserted_base_count,
+        "effect_inserted": inserted_effect_count,
+    }

+ 353 - 0
app/sync/vertical_category_sync.py

@@ -0,0 +1,353 @@
+"""垂直领域分类:从 ODPS 同步基本分类(增量)与效果数据(按日全量)。"""
+
+import re
+from datetime import datetime, timedelta
+from decimal import Decimal
+from zoneinfo import ZoneInfo
+
+from sqlalchemy import bindparam, text
+
+from app.core.config import settings
+from app.db.mysql import SessionLocal
+from app.odps.client import get_odps_client
+
+IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+BATCH_SIZE = 500
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+
+_T_ELEMENT_ALL_CATEGORY_CTE = """
+WITH t_element AS
+(
+    SELECT  t1.post_id AS vid
+            ,t1.global_element_id AS element_id
+            ,t1.element_name AS 元素名称
+            ,t4.stable_id AS stable_id
+            ,t4.parent_stable_id AS parent_stable_id
+            ,t4.name AS 分类名称
+            ,t4.classified_as AS 归类
+            ,t4.source_type AS 维度
+            ,t4.description AS 分类说明
+            ,t4.level AS 分类层级
+            ,t4.path AS 分类路径
+            ,t1.element_type AS 元素维度
+            ,t6.topic_point_type AS 点类型
+            ,t2.contribution AS 贡献度
+            ,t2.share_will AS 分享意愿度
+            ,t2.consume_will AS 消费意愿度
+            ,t2.click_will AS 点击意愿度
+    FROM    loghubods.element_classification_mapping t1
+    LEFT JOIN loghubods.dwd_post_word_contribution_di t2
+    ON      t1.post_id = t2.post_id
+    AND     t1.element_name = t2.word
+    AND     t2.dt = '{bizdate}'
+    LEFT JOIN loghubods.global_element t3
+    ON      t1.global_element_id = t3.id
+    AND     t3.dt = '{bizdate}'
+    LEFT JOIN loghubods.global_category t4
+    ON      t1.global_category_stable_id = t4.stable_id
+    AND     t4.dt = '{bizdate}'
+    LEFT JOIN loghubods.post_decode_topic_point_element t5
+    ON      t1.source_element_id = t5.id
+    AND     t1.post_id = t5.post_id
+    AND     t5.dt = '{bizdate}'
+    LEFT JOIN loghubods.post_decode_topic_point t6
+    ON      t5.topic_point_id = t6.id
+    AND     t6.dt = '{bizdate}'
+    WHERE   t1.dt = '{bizdate}'
+    AND     LENGTH(CAST(t1.post_id AS STRING)) <= 10
+    AND     t3.retired_at_execution_id IS NULL
+    AND     t4.retired_at_execution_id IS NULL
+    AND     t5.id IS NOT NULL
+    AND     t1.element_type = '实质'
+    AND     t4.classified_as = '垂直领域细分'
+)
+,t_element_all_category AS
+(
+    SELECT  e.vid
+            ,e.贡献度
+            ,e.分享意愿度
+            ,e.消费意愿度
+            ,e.点击意愿度
+            ,gc_ancestor.stable_id
+            ,gc_ancestor.parent_stable_id
+            ,gc_ancestor.level
+            ,gc_ancestor.name
+            ,gc_ancestor.classified_as
+            ,gc_ancestor.source_type
+            ,gc_ancestor.description
+    FROM    t_element e
+    JOIN    loghubods.global_category gc_self
+    ON      e.stable_id = gc_self.stable_id
+    AND     gc_self.dt = '{bizdate}'
+    AND     gc_self.retired_at_execution_id IS NULL
+    JOIN    loghubods.global_category gc_ancestor
+    ON      gc_ancestor.dt = '{bizdate}'
+    AND     gc_ancestor.retired_at_execution_id IS NULL
+    AND     gc_ancestor.dt = gc_self.dt
+    WHERE   INSTR(gc_self.path, CONCAT(gc_ancestor.path, '/')) = 1
+    UNION ALL
+    SELECT  vid
+            ,贡献度
+            ,分享意愿度
+            ,消费意愿度
+            ,点击意愿度
+            ,stable_id
+            ,parent_stable_id
+            ,分类层级 AS level
+            ,分类名称 AS name
+            ,归类 AS classified_as
+            ,维度 AS source_type
+            ,分类说明 AS description
+    FROM    t_element
+    WHERE   stable_id IS NOT NULL
+)
+"""
+
+
+def _safe_identifier(name: str) -> str:
+    if not IDENTIFIER_RE.match(name):
+        raise ValueError(f"invalid sql identifier: {name}")
+    return name
+
+
+def _normalize_scalar(value: object) -> object:
+    if isinstance(value, Decimal):
+        return float(value)
+    return value
+
+
+def _normalize_category_id(value: object) -> str:
+    text_value = str(value or "").strip()
+    if not text_value:
+        raise ValueError("category_id 不能为空")
+    return text_value
+
+
+def _yesterday_partition_dt(reference: datetime | None = None) -> str:
+    now = reference or datetime.now(SHANGHAI_TZ)
+    return (now.date() - timedelta(days=1)).strftime("%Y%m%d")
+
+
+def _build_sync_sql(bizdate: str) -> str:
+    return (
+        _T_ELEMENT_ALL_CATEGORY_CTE.format(bizdate=bizdate)
+        + """
+SELECT  CAST(t1.stable_id AS STRING) AS category_id
+        ,CAST(t1.parent_stable_id AS STRING) AS parent_stable_id
+        ,t1.name AS category_name
+        ,t1.level AS category_level
+        ,t1.source_type AS dimension
+        ,t1.classified_as AS classified_as
+        ,COUNT(DISTINCT t1.vid) AS vid_count
+        ,COALESCE(ROUND(
+            SUM(t2.分发回流uv * CASE WHEN t1.贡献度 >= 0.8 THEN t1.贡献度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发曝光pv), 0)
+        , 4), 0) AS rov_score
+        ,COALESCE(ROUND(
+            SUM(t2.分发分享pv * CASE WHEN t1.分享意愿度 >= 0.8 THEN t1.分享意愿度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发曝光pv), 0)
+        , 4), 0) AS str_score
+        ,COALESCE(ROUND(
+            SUM(t2.分发回流uv * CASE WHEN t1.点击意愿度 >= 0.8 THEN t1.点击意愿度 ELSE 0 END)
+            / NULLIF(SUM(t2.分发分享pv), 0)
+        , 4), 0) AS ros_score
+FROM    t_element_all_category t1
+LEFT JOIN loghubods.video_multi_strategy_effect t2
+ON      t1.vid = t2.vid
+AND     t2.dt = '{bizdate}'
+AND     t2.strategy = '当天'
+LEFT JOIN loghubods.global_category t3
+ON      t1.stable_id = t3.stable_id
+AND     t3.dt = '{bizdate}'
+AND     t3.retired_at_execution_id IS NULL
+GROUP BY t1.stable_id
+         ,t1.parent_stable_id
+         ,t1.name
+         ,t1.level
+         ,t1.source_type
+         ,t1.classified_as
+"""
+    ).format(bizdate=bizdate)
+
+
+def _fetch_from_odps(bizdate: str) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
+    sql = _build_sync_sql(bizdate)
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+
+    base_rows: dict[str, dict[str, object]] = {}
+    effect_rows: list[dict[str, object]] = []
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            category_id = _normalize_category_id(record["category_id"])
+            base_rows[category_id] = {
+                "category_id": category_id,
+                "parent_stable_id": str(record["parent_stable_id"] or "").strip() or None,
+                "category_name": record["category_name"],
+                "category_level": record["category_level"],
+                "dimension": record["dimension"],
+                "classified_as": record["classified_as"],
+            }
+            effect_rows.append(
+                {
+                    "category_id": category_id,
+                    "vid_count": record["vid_count"],
+                    "rov_score": _normalize_scalar(record["rov_score"]),
+                    "str_score": _normalize_scalar(record["str_score"]),
+                    "ros_score": _normalize_scalar(record["ros_score"]),
+                    "dt": bizdate,
+                }
+            )
+    return list(base_rows.values()), effect_rows
+
+
+def _ensure_tables() -> None:
+    base_table = _safe_identifier(settings.vertical_category_base_table)
+    effect_table = _safe_identifier(settings.vertical_category_effect_table)
+    create_base_sql = f"""
+    CREATE TABLE IF NOT EXISTS {base_table}
+    (
+        id BIGINT AUTO_INCREMENT COMMENT '自增id' PRIMARY KEY,
+        category_id VARCHAR(64) NOT NULL COMMENT '分类stable_id',
+        parent_stable_id VARCHAR(64) NULL COMMENT '父分类stable_id',
+        category_name VARCHAR(256) NULL COMMENT '分类名称',
+        category_level INT NULL COMMENT '分类层级',
+        dimension VARCHAR(32) NULL COMMENT '维度',
+        classified_as VARCHAR(64) NULL COMMENT '归类',
+        create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+        update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+        UNIQUE KEY uniq_category_id (category_id)
+    )
+    """
+    create_effect_sql = f"""
+    CREATE TABLE IF NOT EXISTS {effect_table}
+    (
+        id BIGINT AUTO_INCREMENT COMMENT '自增id' PRIMARY KEY,
+        category_id VARCHAR(64) NOT NULL COMMENT '分类stable_id',
+        vid_count BIGINT NULL COMMENT '视频数量',
+        rov_score DOUBLE NULL COMMENT 'ROV得分',
+        str_score DOUBLE NULL COMMENT 'STR得分',
+        ros_score DOUBLE NULL COMMENT 'ROS得分',
+        dt VARCHAR(32) NOT NULL COMMENT '分区日期',
+        create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+        update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+        UNIQUE KEY uniq_category_dt (category_id, dt)
+    )
+    """
+    with SessionLocal() as session:
+        session.execute(text(create_base_sql))
+        session.execute(text(create_effect_sql))
+        session.commit()
+
+
+def _load_existing_category_ids(category_ids: list[str]) -> set[str]:
+    if not category_ids:
+        return set()
+
+    base_table = _safe_identifier(settings.vertical_category_base_table)
+    existing: set[str] = set()
+    query_sql = (
+        text(
+            f"""
+        SELECT category_id
+        FROM {base_table}
+        WHERE category_id IN :category_ids
+        """
+        )
+        .bindparams(bindparam("category_ids", expanding=True))
+    )
+
+    with SessionLocal() as session:
+        for start in range(0, len(category_ids), BATCH_SIZE):
+            batch = category_ids[start : start + BATCH_SIZE]
+            rows = session.execute(query_sql, {"category_ids": tuple(batch)}).all()
+            existing.update(str(row[0]) for row in rows)
+    return existing
+
+
+def _insert_base_categories(rows: list[dict[str, object]]) -> int:
+    if not rows:
+        return 0
+
+    base_table = _safe_identifier(settings.vertical_category_base_table)
+    insert_sql = text(
+        f"""
+        INSERT INTO {base_table}
+        (
+            category_id,
+            parent_stable_id,
+            category_name,
+            category_level,
+            dimension,
+            classified_as
+        )
+        VALUES
+        (
+            :category_id,
+            :parent_stable_id,
+            :category_name,
+            :category_level,
+            :dimension,
+            :classified_as
+        )
+        """
+    )
+
+    with SessionLocal() as session:
+        for start in range(0, len(rows), BATCH_SIZE):
+            session.execute(insert_sql, rows[start : start + BATCH_SIZE])
+        session.commit()
+    return len(rows)
+
+
+def _replace_effect_partition(partition_dt: str, rows: list[dict[str, object]]) -> int:
+    effect_table = _safe_identifier(settings.vertical_category_effect_table)
+    delete_sql = text(f"DELETE FROM {effect_table} WHERE dt = :dt")
+    insert_sql = text(
+        f"""
+        INSERT INTO {effect_table}
+        (
+            category_id,
+            vid_count,
+            rov_score,
+            str_score,
+            ros_score,
+            dt
+        )
+        VALUES
+        (
+            :category_id,
+            :vid_count,
+            :rov_score,
+            :str_score,
+            :ros_score,
+            :dt
+        )
+        """
+    )
+
+    with SessionLocal() as session:
+        session.execute(delete_sql, {"dt": partition_dt})
+        for start in range(0, len(rows), BATCH_SIZE):
+            session.execute(insert_sql, rows[start : start + BATCH_SIZE])
+        session.commit()
+    return len(rows)
+
+
+def sync_vertical_categories(partition_dt: str | None = None) -> dict[str, int | str]:
+    """同步指定分区(默认上海时区昨天)的基本分类增量与效果全量。"""
+    _ensure_tables()
+    bizdate = partition_dt or _yesterday_partition_dt()
+
+    base_rows, effect_rows = _fetch_from_odps(bizdate)
+    existing_ids = _load_existing_category_ids([str(row["category_id"]) for row in base_rows])
+    incremental_rows = [row for row in base_rows if str(row["category_id"]) not in existing_ids]
+    inserted_base_count = _insert_base_categories(incremental_rows)
+    inserted_effect_count = _replace_effect_partition(bizdate, effect_rows)
+
+    return {
+        "partition_dt": bizdate,
+        "base_fetched": len(base_rows),
+        "base_inserted": inserted_base_count,
+        "effect_inserted": inserted_effect_count,
+    }

+ 12 - 0
frontend/category-tree.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>需求汇总</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/category-tree-main.tsx"></script>
+  </body>
+</html>

+ 24 - 20
frontend/src/App.tsx

@@ -23,6 +23,7 @@ import type { SortOrder } from "antd/es/table/interface";
 import dayjs from "dayjs";
 import type { Dayjs } from "dayjs";
 import HotContentSourcePage from "./HotContentSourcePage";
+import DemandNavBar from "./DemandNavBar";
 import EllipsisCell from "./EllipsisCell";
 
 type DemandPoolItem = {
@@ -1895,29 +1896,32 @@ function App() {
   }
 
   return (
-    <div className="page">
-      <div className="hero">
-        <Typography.Title level={2} className="hero-title">
-          需求池数据看板
-        </Typography.Title>
-        <div className="hero-subtitle">
-          <Tag color="blue">数据检索</Tag>
-          <Tag color="cyan">策略筛选</Tag>
-          <Tag color="geekblue">日期范围分析</Tag>
-          <Tag color="purple">需求筛选</Tag>
+    <>
+      <DemandNavBar active="dashboard" />
+      <div className="page">
+        <div className="hero">
+          <Typography.Title level={2} className="hero-title">
+            需求池数据看板
+          </Typography.Title>
+          <div className="hero-subtitle">
+            <Tag color="blue">数据检索</Tag>
+            <Tag color="cyan">策略筛选</Tag>
+            <Tag color="geekblue">日期范围分析</Tag>
+            <Tag color="purple">需求筛选</Tag>
+          </div>
         </div>
-      </div>
 
-      <div className="dashboard-shell">
-        <Tabs
-          className="main-tabs demand-nav-tabs"
-          activeKey={activeTab}
-          onChange={setActiveTab}
-          tabBarGutter={16}
-          items={tabItems}
-        />
+        <div className="dashboard-shell">
+          <Tabs
+            className="main-tabs demand-nav-tabs"
+            activeKey={activeTab}
+            onChange={setActiveTab}
+            tabBarGutter={16}
+            items={tabItems}
+          />
+        </div>
       </div>
-    </div>
+    </>
   );
 }
 

+ 1360 - 0
frontend/src/CategoryEffectTreeApp.tsx

@@ -0,0 +1,1360 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Alert, Button, Checkbox, DatePicker, Select, Spin, Tooltip } from "antd";
+import {
+  CaretDownFilled,
+  CaretRightFilled,
+  ReloadOutlined,
+  ShrinkOutlined,
+  ZoomInOutlined,
+  ZoomOutOutlined,
+  CompressOutlined,
+  FullscreenExitOutlined,
+  FullscreenOutlined,
+} from "@ant-design/icons";
+import dayjs from "dayjs";
+import type { Dayjs } from "dayjs";
+
+const API_BASE_URL =
+  import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
+
+const NODE_WIDTH = 236;
+const NODE_HEIGHT = 58;
+const COLUMN_GAP = 96;
+const ROW_GAP = 12;
+const CANVAS_PADDING = 48;
+const LEVEL_HEADER_HEIGHT = 44;
+const MIN_ZOOM = 0.08;
+const MAX_ZOOM = 2;
+const DEFAULT_EXPAND_LEVEL = 4;
+const DEFAULT_ZOOM = 0.7;
+
+const getResolvedApiBaseUrl = () => {
+  if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
+    return API_BASE_URL;
+  }
+  return new URL(API_BASE_URL, window.location.origin).toString();
+};
+
+type CategoryNode = {
+  category_id: string;
+  parent_stable_id: string | null;
+  category_name: string | null;
+  category_level: number | null;
+  vid_count: number | null;
+  rov_score: number | null;
+  is_leaf: boolean;
+};
+
+type ElementNode = {
+  element_id: string;
+  stable_id: string | null;
+  element_name: string | null;
+  vid_count: number | null;
+  rov_score: number | null;
+};
+
+type TreeResponse = {
+  dt: string;
+  available_dates: string[];
+  category_min_rov_score: number;
+  category_max_rov_score: number;
+  element_min_rov_score: number;
+  element_max_rov_score: number;
+  categories: CategoryNode[];
+  elements: ElementNode[];
+};
+
+type LayoutKind = "category" | "element";
+
+type LayoutNode = {
+  key: string;
+  kind: LayoutKind;
+  depth: number;
+  x: number;
+  y: number;
+  category?: CategoryNode;
+  element?: ElementNode;
+  parentKey: string | null;
+  childKeys: string[];
+  categoryChildKeys: string[];
+  elementChildKeys: string[];
+  hasCategoryChildren: boolean;
+  hasElements: boolean;
+  categoryExpanded: boolean;
+  elementsExpanded: boolean;
+  categoryBadgeCount: number;
+  elementBadgeCount: number;
+  rov_score: number | null;
+  title: string;
+};
+
+type LevelHeader = {
+  depth: number;
+  label: string;
+  x: number;
+};
+
+function formatDtLabel(dt: string): string {
+  if (/^\d{8}$/.test(dt)) {
+    return `${dt.slice(0, 4)}-${dt.slice(4, 6)}-${dt.slice(6, 8)}`;
+  }
+  return dt;
+}
+
+function formatScore(value: number | null): string {
+  if (value === null || value === undefined || Number.isNaN(value)) {
+    return "-";
+  }
+  return value.toFixed(4);
+}
+
+function isZeroRovScore(score: number | null): boolean {
+  return score !== null && score !== undefined && !Number.isNaN(score) && Math.abs(score) < 1e-9;
+}
+
+const ZERO_ROV_STYLE = {
+  background: "linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%)",
+  border: "#94a3b8",
+  text: "#64748b",
+};
+
+function rovToPastelStyle(
+  score: number | null,
+  minScore: number,
+  maxScore: number,
+): { background: string; border: string; text: string } {
+  if (score === null || score === undefined || Number.isNaN(score)) {
+    return {
+      background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%)",
+      border: "#dbe3ef",
+      text: "#64748b",
+    };
+  }
+
+  if (isZeroRovScore(score)) {
+    return ZERO_ROV_STYLE;
+  }
+
+  const span = maxScore - minScore;
+  const ratio = span <= 1e-9 ? 0.5 : (score - minScore) / span;
+  const clamped = Math.min(1, Math.max(0, ratio));
+
+  const red = { r: 252, g: 165, b: 165 };
+  const white = { r: 255, g: 255, b: 255 };
+  const green = { r: 134, g: 239, b: 172 };
+  let r: number;
+  let g: number;
+  let b: number;
+  if (clamped <= 0.5) {
+    const t = clamped / 0.5;
+    r = Math.round(red.r + (white.r - red.r) * t);
+    g = Math.round(red.g + (white.g - red.g) * t);
+    b = Math.round(red.b + (white.b - red.b) * t);
+  } else {
+    const t = (clamped - 0.5) / 0.5;
+    r = Math.round(white.r + (green.r - white.r) * t);
+    g = Math.round(white.g + (green.g - white.g) * t);
+    b = Math.round(white.b + (green.b - white.b) * t);
+  }
+
+  return {
+    background: `linear-gradient(135deg, rgb(${r}, ${g}, ${b}) 0%, rgb(${Math.min(255, r + 18)}, ${Math.min(255, g + 18)}, ${Math.min(255, b + 18)}) 100%)`,
+    border: `rgba(${Math.round(r * 0.55)}, ${Math.round(g * 0.55)}, ${Math.round(b * 0.55)}, 0.55)`,
+    text: "#4a3728",
+  };
+}
+
+function buildChildrenMap(categories: CategoryNode[]): Map<string, CategoryNode[]> {
+  const map = new Map<string, CategoryNode[]>();
+  for (const category of categories) {
+    const parentId = category.parent_stable_id;
+    if (!parentId) {
+      continue;
+    }
+    const siblings = map.get(parentId) ?? [];
+    siblings.push(category);
+    map.set(parentId, siblings);
+  }
+  for (const siblings of map.values()) {
+    siblings.sort((a, b) =>
+      a.category_id.localeCompare(b.category_id, undefined, { numeric: true }),
+    );
+  }
+  return map;
+}
+
+function buildElementsByCategory(elements: ElementNode[]): Map<string, ElementNode[]> {
+  const map = new Map<string, ElementNode[]>();
+  for (const element of elements) {
+    const categoryId = element.stable_id;
+    if (!categoryId) {
+      continue;
+    }
+    const siblings = map.get(categoryId) ?? [];
+    siblings.push(element);
+    map.set(categoryId, siblings);
+  }
+  for (const siblings of map.values()) {
+    siblings.sort((a, b) =>
+      a.element_id.localeCompare(b.element_id, undefined, { numeric: true }),
+    );
+  }
+  return map;
+}
+
+function isMissingParent(category: CategoryNode, allIds: Set<string>): boolean {
+  return !category.parent_stable_id || !allIds.has(category.parent_stable_id);
+}
+
+function findRootCategories(categories: CategoryNode[]): CategoryNode[] {
+  const allIds = new Set(categories.map((item) => item.category_id));
+  return categories
+    .filter((item) => {
+      if (!isMissingParent(item, allIds)) {
+        return false;
+      }
+      const level = item.category_level ?? 1;
+      return level <= 1;
+    })
+    .sort((a, b) =>
+      a.category_id.localeCompare(b.category_id, undefined, { numeric: true }),
+    );
+}
+
+function findDetachedCategories(categories: CategoryNode[]): CategoryNode[] {
+  const allIds = new Set(categories.map((item) => item.category_id));
+  return categories
+    .filter((item) => {
+      if (!isMissingParent(item, allIds)) {
+        return false;
+      }
+      const level = item.category_level ?? 1;
+      return level > 1;
+    })
+    .sort((a, b) => {
+      const levelA = a.category_level ?? 1;
+      const levelB = b.category_level ?? 1;
+      if (levelA !== levelB) {
+        return levelA - levelB;
+      }
+      return a.category_id.localeCompare(b.category_id, undefined, { numeric: true });
+    });
+}
+
+function buildCategoryDepthMap(
+  categories: CategoryNode[],
+  childrenMap: Map<string, CategoryNode[]>,
+  roots: CategoryNode[],
+): Map<string, number> {
+  const depthMap = new Map<string, number>();
+  const detached = findDetachedCategories(categories);
+
+  const visit = (categoryId: string, fallbackLevel: number) => {
+    if (depthMap.has(categoryId)) {
+      return;
+    }
+    const category = categories.find((item) => item.category_id === categoryId);
+    const level = category?.category_level ?? fallbackLevel;
+    depthMap.set(categoryId, level);
+    for (const child of childrenMap.get(categoryId) ?? []) {
+      visit(child.category_id, level + 1);
+    }
+  };
+
+  for (const root of roots) {
+    visit(root.category_id, root.category_level ?? 1);
+  }
+  for (const item of detached) {
+    if (!depthMap.has(item.category_id)) {
+      visit(item.category_id, item.category_level ?? 1);
+    }
+  }
+  for (const category of categories) {
+    if (!depthMap.has(category.category_id)) {
+      depthMap.set(category.category_id, category.category_level ?? 1);
+    }
+  }
+  return depthMap;
+}
+
+function getEffectiveCategoryLevel(
+  category: CategoryNode,
+  depthMap: Map<string, number>,
+): number {
+  return category.category_level ?? depthMap.get(category.category_id) ?? 1;
+}
+
+function getLayoutDepth(category: CategoryNode, depthMap: Map<string, number>): number {
+  return Math.max(0, getEffectiveCategoryLevel(category, depthMap) - 1);
+}
+
+const EXPAND_ALL_LEVEL = "all" as const;
+type ExpandLevelFilter = number | typeof EXPAND_ALL_LEVEL;
+
+function isCategoryVisibleByExpandFilter(
+  category: CategoryNode,
+  depthMap: Map<string, number>,
+  levelFilter: ExpandLevelFilter,
+): boolean {
+  if (levelFilter === EXPAND_ALL_LEVEL) {
+    return true;
+  }
+  return getEffectiveCategoryLevel(category, depthMap) <= levelFilter;
+}
+
+type RovScale = { min: number; max: number };
+
+function buildCategoryRovScalesByLevel(
+  categories: CategoryNode[],
+  depthMap: Map<string, number>,
+): Map<number, RovScale> {
+  const scales = new Map<number, RovScale>();
+  for (const category of categories) {
+    if (isZeroRovScore(category.rov_score) || category.rov_score === null) {
+      continue;
+    }
+    const level = getEffectiveCategoryLevel(category, depthMap);
+    const score = category.rov_score;
+    const current = scales.get(level);
+    if (!current) {
+      scales.set(level, { min: score, max: score });
+      continue;
+    }
+    current.min = Math.min(current.min, score);
+    current.max = Math.max(current.max, score);
+  }
+  return scales;
+}
+
+function buildElementRovScale(elements: ElementNode[]): RovScale {
+  let min = 0;
+  let max = 0;
+  let initialized = false;
+  for (const element of elements) {
+    if (isZeroRovScore(element.rov_score) || element.rov_score === null) {
+      continue;
+    }
+    const score = element.rov_score;
+    if (!initialized) {
+      min = score;
+      max = score;
+      initialized = true;
+      continue;
+    }
+    min = Math.min(min, score);
+    max = Math.max(max, score);
+  }
+  return { min, max };
+}
+
+function resolveNodeRovScale(
+  node: LayoutNode,
+  categoryRovScalesByLevel: Map<number, RovScale>,
+  elementRovScale: RovScale,
+  depthMap: Map<string, number>,
+): RovScale {
+  if (node.kind === "element") {
+    return elementRovScale;
+  }
+  if (!node.category) {
+    return { min: 0, max: 0 };
+  }
+  const level = getEffectiveCategoryLevel(node.category, depthMap);
+  return categoryRovScalesByLevel.get(level) ?? { min: 0, max: 0 };
+}
+
+function computeExpandedCategoryIds(
+  categories: CategoryNode[],
+  childrenMap: Map<string, CategoryNode[]>,
+  depthMap: Map<string, number>,
+  levelFilter: ExpandLevelFilter,
+): Set<string> {
+  const withChildren = categories.filter(
+    (item) => (childrenMap.get(item.category_id) ?? []).length > 0,
+  );
+  if (levelFilter === EXPAND_ALL_LEVEL) {
+    return new Set(withChildren.map((item) => item.category_id));
+  }
+  return new Set(
+    withChildren
+      .filter(
+        (item) => getEffectiveCategoryLevel(item, depthMap) < levelFilter,
+      )
+      .map((item) => item.category_id),
+  );
+}
+
+function collectDescendantCategoryIds(
+  categoryId: string,
+  childrenMap: Map<string, CategoryNode[]>,
+): string[] {
+  const result: string[] = [];
+  const stack = [...(childrenMap.get(categoryId) ?? [])];
+  while (stack.length > 0) {
+    const current = stack.pop()!;
+    result.push(current.category_id);
+    stack.push(...(childrenMap.get(current.category_id) ?? []));
+  }
+  return result;
+}
+
+function buildConnectorPath(parent: LayoutNode, child: LayoutNode): string {
+  const x1 = parent.x + NODE_WIDTH;
+  const y1 = parent.y + NODE_HEIGHT / 2;
+  const x2 = child.x;
+  const y2 = child.y + NODE_HEIGHT / 2;
+  const midX = x1 + (x2 - x1) / 2;
+  return `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`;
+}
+
+type TreeNodeCardProps = {
+  node: LayoutNode;
+  categoryRovScalesByLevel: Map<number, RovScale>;
+  elementRovScale: RovScale;
+  categoryDepthMap: Map<string, number>;
+  active: boolean;
+  onToggleCategory: () => void;
+  onToggleElements: () => void;
+};
+
+function TreeNodeCard({
+  node,
+  categoryRovScalesByLevel,
+  elementRovScale,
+  categoryDepthMap,
+  active,
+  onToggleCategory,
+  onToggleElements,
+}: TreeNodeCardProps) {
+  const { min: minScore, max: maxScore } = resolveNodeRovScale(
+    node,
+    categoryRovScalesByLevel,
+    elementRovScale,
+    categoryDepthMap,
+  );
+  const isZeroScore = isZeroRovScore(node.rov_score);
+  const palette = rovToPastelStyle(node.rov_score, minScore, maxScore);
+  const tooltip = (
+    <div className="cet-tooltip">
+      <div>{node.title}</div>
+      <div>ROV {formatScore(node.rov_score)}</div>
+      {isZeroScore ? <div className="cet-tooltip-note">得分为 0,不参与色阶</div> : null}
+      {node.kind === "category" && node.category?.vid_count != null ? (
+        <div>视频 {node.category.vid_count}</div>
+      ) : null}
+      {node.kind === "element" && node.element?.vid_count != null ? (
+        <div>视频 {node.element.vid_count}</div>
+      ) : null}
+      {node.hasElements ? (
+        <div>
+          {node.elementsExpanded
+            ? "已展开元素"
+            : `点击右侧「${node.elementBadgeCount} 元素」展开`}
+        </div>
+      ) : null}
+    </div>
+  );
+
+  const handleMainClick = () => {
+    if (node.kind === "element") {
+      return;
+    }
+    if (node.hasCategoryChildren) {
+      onToggleCategory();
+      return;
+    }
+    if (node.hasElements) {
+      onToggleElements();
+    }
+  };
+
+  return (
+    <Tooltip title={tooltip} placement="top" mouseEnterDelay={0.35}>
+      <div
+        className={`cet-node${active ? " cet-node--active" : ""}${node.kind === "element" ? " cet-node--element" : ""}${node.hasElements && !node.hasCategoryChildren ? " cet-node--leaf" : ""}${isZeroScore ? " cet-node--zero" : ""}`}
+        style={{
+          left: node.x,
+          top: node.y,
+          width: NODE_WIDTH,
+          height: NODE_HEIGHT,
+          background: palette.background,
+          borderColor: active ? "#3b82f6" : palette.border,
+          color: palette.text,
+        }}
+      >
+        {node.hasCategoryChildren ? (
+          <button
+            type="button"
+            className="cet-node-caret-btn"
+            aria-label={node.categoryExpanded ? "收起子分类" : "展开子分类"}
+            onClick={(event) => {
+              event.stopPropagation();
+              onToggleCategory();
+            }}
+          >
+            {node.categoryExpanded ? <CaretDownFilled /> : <CaretRightFilled />}
+          </button>
+        ) : (
+          <span className="cet-node-caret cet-node-caret--placeholder" />
+        )}
+
+        <button type="button" className="cet-node-main" onClick={handleMainClick}>
+          <span className="cet-node-title-row">
+            <span className="cet-node-label" title={node.title}>
+              {node.title}
+            </span>
+            <span
+              className={`cet-node-kind-tag${node.kind === "element" ? " cet-node-kind-tag--element" : " cet-node-kind-tag--category"}`}
+            >
+              {node.kind === "element" ? "元素" : "分类"}
+            </span>
+          </span>
+          <span className="cet-node-score">
+            ROV {formatScore(node.rov_score)}
+            {isZeroScore ? <span className="cet-node-zero-tag">无效果</span> : null}
+          </span>
+        </button>
+
+        <div className="cet-node-badges">
+          {node.hasCategoryChildren && !node.categoryExpanded ? (
+            <button
+              type="button"
+              className="cet-node-badge cet-node-badge--category"
+              onClick={(event) => {
+                event.stopPropagation();
+                onToggleCategory();
+              }}
+            >
+              {node.categoryBadgeCount}
+              <CaretRightFilled />
+            </button>
+          ) : null}
+          {node.hasElements ? (
+            <button
+              type="button"
+              className={`cet-node-badge cet-node-badge--element${node.elementsExpanded ? " cet-node-badge--expanded" : ""}`}
+              onClick={(event) => {
+                event.stopPropagation();
+                onToggleElements();
+              }}
+            >
+              {node.elementBadgeCount} 元素
+              {node.elementsExpanded ? <CaretDownFilled /> : <CaretRightFilled />}
+            </button>
+          ) : null}
+        </div>
+      </div>
+    </Tooltip>
+  );
+}
+
+export default function CategoryEffectTreeApp() {
+  const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
+  const [appliedDt, setAppliedDt] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+  const [data, setData] = useState<TreeResponse | null>(null);
+  const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<string>>(new Set());
+  const [expandedElementParents, setExpandedElementParents] = useState<Set<string>>(new Set());
+  const [activeKey, setActiveKey] = useState<string | null>(null);
+  const [showInvalidNodes, setShowInvalidNodes] = useState(false);
+  const [expandLevelFilter, setExpandLevelFilter] = useState<ExpandLevelFilter>(DEFAULT_EXPAND_LEVEL);
+  const [zoom, setZoom] = useState(DEFAULT_ZOOM);
+  const [canvasScrollLeft, setCanvasScrollLeft] = useState(0);
+  const [treeFullscreen, setTreeFullscreen] = useState(false);
+  const viewportRef = useRef<HTMLDivElement | null>(null);
+
+  const fetchTree = useCallback(async (dt?: string) => {
+    setLoading(true);
+    setError("");
+    try {
+      const resolvedBase = getResolvedApiBaseUrl();
+      const baseWithSlash = resolvedBase.endsWith("/")
+        ? resolvedBase
+        : `${resolvedBase}/`;
+      const url = new URL("vertical-category/tree", baseWithSlash);
+      if (dt) {
+        url.searchParams.set("dt", dt);
+      }
+      const response = await fetch(url.toString(), {
+        method: "GET",
+        headers: { Accept: "application/json" },
+      });
+      if (!response.ok) {
+        const detail = await response.text();
+        throw new Error(detail || `HTTP ${response.status}`);
+      }
+      const payload = (await response.json()) as TreeResponse;
+      setData(payload);
+      setAppliedDt(payload.dt);
+      setSelectedDate(dayjs(payload.dt, "YYYYMMDD"));
+      setExpandedCategoryIds(new Set());
+      setExpandedElementParents(new Set());
+      setExpandLevelFilter(DEFAULT_EXPAND_LEVEL);
+      setZoom(DEFAULT_ZOOM);
+      setActiveKey(null);
+    } catch (queryError) {
+      setError(
+        queryError instanceof Error ? queryError.message : "查询失败,请重试",
+      );
+      setData(null);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    void fetchTree();
+  }, [fetchTree]);
+
+  const childrenMap = useMemo(
+    () => buildChildrenMap(data?.categories ?? []),
+    [data?.categories],
+  );
+  const elementsByCategory = useMemo(
+    () => buildElementsByCategory(data?.elements ?? []),
+    [data?.elements],
+  );
+  const roots = useMemo(
+    () => findRootCategories(data?.categories ?? []),
+    [data?.categories],
+  );
+  const detachedCategories = useMemo(
+    () => findDetachedCategories(data?.categories ?? []),
+    [data?.categories],
+  );
+  const categoryDepthMap = useMemo(
+    () => buildCategoryDepthMap(data?.categories ?? [], childrenMap, roots),
+    [data?.categories, childrenMap, roots],
+  );
+  const availableExpandLevels = useMemo(() => {
+    if (!data) {
+      return [1];
+    }
+    const levels = new Set<number>();
+    for (const category of data.categories) {
+      levels.add(getEffectiveCategoryLevel(category, categoryDepthMap));
+    }
+    return [...levels].sort((a, b) => a - b);
+  }, [data, categoryDepthMap]);
+
+  const expandLevelOptions = useMemo(() => {
+    const levelOptions = availableExpandLevels.map((level) => ({
+      value: level,
+      label: `L${level}`,
+    }));
+    return [
+      ...levelOptions,
+      { value: EXPAND_ALL_LEVEL, label: "全部展开" },
+    ];
+  }, [availableExpandLevels]);
+
+  useEffect(() => {
+    if (!data) {
+      return;
+    }
+    setExpandedCategoryIds(
+      computeExpandedCategoryIds(
+        data.categories,
+        childrenMap,
+        categoryDepthMap,
+        expandLevelFilter,
+      ),
+    );
+  }, [data, childrenMap, categoryDepthMap, expandLevelFilter]);
+
+  const handleExpandLevelFilterChange = (value: ExpandLevelFilter) => {
+    setExpandLevelFilter(value);
+  };
+
+  const { layoutNodes, connectors, levelHeaders, canvasWidth, canvasHeight } = useMemo(() => {
+    if (!data) {
+      return {
+        layoutNodes: [] as LayoutNode[],
+        connectors: [] as string[],
+        levelHeaders: [] as LevelHeader[],
+        canvasWidth: 800,
+        canvasHeight: 480,
+      };
+    }
+
+    const nodeMap = new Map<string, LayoutNode>();
+
+    const buildCategoryNode = (
+      category: CategoryNode,
+      depth: number,
+      parentKey: string | null,
+      options: {
+        categoryChildKeys: string[];
+        elementChildKeys: string[];
+        hasCategoryChildren: boolean;
+        hasElements: boolean;
+        categoryExpanded: boolean;
+        elementsExpanded: boolean;
+        categoryBadgeCount: number;
+        elementBadgeCount: number;
+      },
+    ): LayoutNode => {
+      const childKeys = [...options.categoryChildKeys, ...options.elementChildKeys];
+
+      const layoutNode: LayoutNode = {
+        key: `category:${category.category_id}`,
+        kind: "category",
+        depth,
+        x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
+        y: 0,
+        category,
+        parentKey,
+        childKeys,
+        categoryChildKeys: options.categoryChildKeys,
+        elementChildKeys: options.elementChildKeys,
+        hasCategoryChildren: options.hasCategoryChildren,
+        hasElements: options.hasElements,
+        categoryExpanded: options.categoryExpanded,
+        elementsExpanded: options.elementsExpanded,
+        categoryBadgeCount: options.categoryBadgeCount,
+        elementBadgeCount: options.elementBadgeCount,
+        rov_score: category.rov_score,
+        title: category.category_name ?? category.category_id,
+      };
+      nodeMap.set(layoutNode.key, layoutNode);
+      return layoutNode;
+    };
+
+    const buildElementNode = (
+      element: ElementNode,
+      depth: number,
+      parentKey: string,
+    ): LayoutNode => {
+      const layoutNode: LayoutNode = {
+        key: `element:${element.element_id}`,
+        kind: "element",
+        depth,
+        x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
+        y: 0,
+        element,
+        parentKey,
+        childKeys: [],
+        categoryChildKeys: [],
+        elementChildKeys: [],
+        hasCategoryChildren: false,
+        hasElements: false,
+        categoryExpanded: false,
+        elementsExpanded: false,
+        categoryBadgeCount: 0,
+        elementBadgeCount: 0,
+        rov_score: element.rov_score,
+        title: element.element_name ?? element.element_id,
+      };
+      nodeMap.set(layoutNode.key, layoutNode);
+      return layoutNode;
+    };
+
+    const buildVisibleTree = (
+      category: CategoryNode,
+      parentKey: string | null,
+    ): void => {
+      const layoutDepth = getLayoutDepth(category, categoryDepthMap);
+      const childCategories = childrenMap.get(category.category_id) ?? [];
+      const allChildElements = elementsByCategory.get(category.category_id) ?? [];
+      const childElements = showInvalidNodes
+        ? allChildElements
+        : allChildElements.filter((item) => !isZeroRovScore(item.rov_score));
+      const visibleChildCategories = showInvalidNodes
+        ? childCategories
+        : childCategories.filter((item) => !isZeroRovScore(item.rov_score));
+
+      const hasCategoryChildren = childCategories.length > 0;
+      const hasElements = childElements.length > 0;
+      const categoryExpanded = expandedCategoryIds.has(category.category_id);
+      const elementsExpanded = expandedElementParents.has(category.category_id);
+      const showSelf = showInvalidNodes || !isZeroRovScore(category.rov_score);
+      const passThrough = !showSelf;
+      const traverseCategories = passThrough || categoryExpanded;
+
+      const categoryChildKeys =
+        traverseCategories && hasCategoryChildren
+          ? childCategories.map((child) => `category:${child.category_id}`)
+          : [];
+      const shouldShowElements =
+        hasElements && (elementsExpanded || (passThrough && childElements.length > 0));
+      const elementChildKeys = shouldShowElements
+        ? childElements.map((element) => `element:${element.element_id}`)
+        : [];
+
+      let linkParentKey = parentKey;
+
+      if (showSelf) {
+        const node = buildCategoryNode(category, layoutDepth, parentKey, {
+          categoryChildKeys,
+          elementChildKeys,
+          hasCategoryChildren,
+          hasElements,
+          categoryExpanded,
+          elementsExpanded,
+          categoryBadgeCount: visibleChildCategories.length,
+          elementBadgeCount: childElements.length,
+        });
+        linkParentKey = node.key;
+      }
+
+      if (traverseCategories) {
+        for (const childKey of categoryChildKeys) {
+          const childId = childKey.slice("category:".length);
+          const childCategory = data.categories.find((item) => item.category_id === childId);
+          if (childCategory) {
+            buildVisibleTree(childCategory, linkParentKey);
+          }
+        }
+      }
+
+      if (elementChildKeys.length > 0 && linkParentKey) {
+        const parentLayoutDepth = getLayoutDepth(category, categoryDepthMap);
+        const elementLayoutDepth =
+          categoryChildKeys.length > 0
+            ? Math.min(
+                ...categoryChildKeys.map((childKey) => {
+                  const childId = childKey.slice("category:".length);
+                  const childCategory = data.categories.find(
+                    (item) => item.category_id === childId,
+                  );
+                  return childCategory
+                    ? getLayoutDepth(childCategory, categoryDepthMap)
+                    : parentLayoutDepth + 1;
+                }),
+              )
+            : parentLayoutDepth + 1;
+        for (const childKey of elementChildKeys) {
+          const childId = childKey.slice("element:".length);
+          const childElement = data.elements.find((item) => item.element_id === childId);
+          if (childElement) {
+            buildElementNode(childElement, elementLayoutDepth, linkParentKey);
+          }
+        }
+      }
+    };
+
+    for (const root of roots) {
+      buildVisibleTree(root, null);
+    }
+
+    for (const detached of detachedCategories) {
+      if (!isCategoryVisibleByExpandFilter(detached, categoryDepthMap, expandLevelFilter)) {
+        continue;
+      }
+      buildVisibleTree(detached, null);
+    }
+
+    for (const node of nodeMap.values()) {
+      node.childKeys = [];
+      node.categoryChildKeys = [];
+      node.elementChildKeys = [];
+    }
+    for (const node of nodeMap.values()) {
+      if (!node.parentKey) {
+        continue;
+      }
+      const parent = nodeMap.get(node.parentKey);
+      if (!parent) {
+        continue;
+      }
+      parent.childKeys.push(node.key);
+      if (node.kind === "category") {
+        parent.categoryChildKeys.push(node.key);
+      } else {
+        parent.elementChildKeys.push(node.key);
+      }
+    }
+    for (const node of nodeMap.values()) {
+      if (node.categoryChildKeys.length > 0 || node.elementChildKeys.length > 0) {
+        node.childKeys = [...node.categoryChildKeys, ...node.elementChildKeys];
+      }
+    }
+
+    const assignYPositions = (nodeKey: string, startY: number): number => {
+      const node = nodeMap.get(nodeKey);
+      if (!node) {
+        return startY;
+      }
+
+      if (node.childKeys.length === 0) {
+        node.y = startY;
+        return startY + NODE_HEIGHT + ROW_GAP;
+      }
+
+      let cursor = startY;
+      for (const childKey of node.childKeys) {
+        cursor = assignYPositions(childKey, cursor);
+      }
+
+      const firstChild = nodeMap.get(node.childKeys[0]);
+      const lastChild = nodeMap.get(node.childKeys[node.childKeys.length - 1]);
+      if (firstChild && lastChild) {
+        if (node.categoryChildKeys.length > 0 && node.elementChildKeys.length > 0) {
+          const firstCategoryChild = nodeMap.get(node.categoryChildKeys[0]);
+          const lastCategoryChild = nodeMap.get(
+            node.categoryChildKeys[node.categoryChildKeys.length - 1],
+          );
+          if (firstCategoryChild && lastCategoryChild) {
+            node.y = (firstCategoryChild.y + lastCategoryChild.y) / 2;
+          }
+        } else {
+          node.y = (firstChild.y + lastChild.y) / 2;
+        }
+      }
+      return cursor;
+    };
+
+    const detachedIds = new Set(detachedCategories.map((item) => item.category_id));
+
+    let yCursor = CANVAS_PADDING;
+    const connectedTopLevelKeys = [...nodeMap.values()]
+      .filter((node) => node.parentKey === null && !detachedIds.has(node.category?.category_id ?? ""))
+      .sort((a, b) => a.depth - b.depth || a.title.localeCompare(b.title, "zh-CN"));
+    for (const node of connectedTopLevelKeys) {
+      yCursor = assignYPositions(node.key, yCursor);
+    }
+
+    const refreshMaxBottomByDepth = () => {
+      const maxBottomByDepth = new Map<number, number>();
+      for (const node of nodeMap.values()) {
+        const bottom = node.y + NODE_HEIGHT;
+        maxBottomByDepth.set(node.depth, Math.max(maxBottomByDepth.get(node.depth) ?? 0, bottom));
+      }
+      return maxBottomByDepth;
+    };
+
+    let maxBottomByDepth = refreshMaxBottomByDepth();
+
+    const detachedTopLevelKeys = [...nodeMap.values()]
+      .filter(
+        (node) =>
+          node.parentKey === null &&
+          node.kind === "category" &&
+          detachedIds.has(node.category?.category_id ?? ""),
+      )
+      .sort((a, b) => a.depth - b.depth || a.title.localeCompare(b.title, "zh-CN"));
+
+    for (const node of detachedTopLevelKeys) {
+      const columnBottom = maxBottomByDepth.get(node.depth);
+      const startY =
+        columnBottom !== undefined
+          ? columnBottom + ROW_GAP
+          : CANVAS_PADDING;
+      yCursor = assignYPositions(node.key, startY);
+      maxBottomByDepth = refreshMaxBottomByDepth();
+    }
+
+    const layoutNodes = [...nodeMap.values()];
+    const connectors = layoutNodes.flatMap((node) =>
+      node.childKeys
+        .map((childKey) => {
+          const child = nodeMap.get(childKey);
+          if (!child) {
+            return null;
+          }
+          return buildConnectorPath(node, child);
+        })
+        .filter((path): path is string => Boolean(path)),
+    );
+
+    const maxDepth = layoutNodes.reduce((max, node) => Math.max(max, node.depth), 0);
+    const maxY = layoutNodes.reduce((max, node) => Math.max(max, node.y + NODE_HEIGHT), 0);
+
+    const levelHeaders: LevelHeader[] = [];
+    for (let depth = 0; depth <= maxDepth; depth += 1) {
+      const nodesAtDepth = layoutNodes.filter((node) => node.depth === depth);
+      if (nodesAtDepth.length === 0) {
+        continue;
+      }
+      const categoryNodes = nodesAtDepth.filter((node) => node.kind === "category");
+      const elementOnly = categoryNodes.length === 0;
+      let label = "元素";
+      if (!elementOnly) {
+        label = `L${depth + 1}`;
+      }
+      levelHeaders.push({
+        depth,
+        label,
+        x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
+      });
+    }
+
+    return {
+      layoutNodes,
+      connectors,
+      levelHeaders,
+      canvasWidth: (maxDepth + 1) * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING * 2,
+      canvasHeight: Math.max(420, maxY + CANVAS_PADDING),
+    };
+  }, [data, roots, detachedCategories, childrenMap, elementsByCategory, expandedCategoryIds, expandedElementParents, showInvalidNodes, expandLevelFilter, categoryDepthMap]);
+
+  const fitToView = useCallback(() => {
+    const viewport = viewportRef.current;
+    if (!viewport || canvasWidth <= 0 || canvasHeight <= 0) {
+      return;
+    }
+    const padding = 32;
+    const scaleX = (viewport.clientWidth - padding) / canvasWidth;
+    const scaleY = (viewport.clientHeight - padding) / canvasHeight;
+    const nextZoom = Math.min(1, scaleX, scaleY);
+    setZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, nextZoom)));
+  }, [canvasWidth, canvasHeight]);
+
+  useEffect(() => {
+    const viewport = viewportRef.current;
+    if (!viewport) {
+      return;
+    }
+    const onWheel = (event: WheelEvent) => {
+      if (!event.ctrlKey && !event.metaKey) {
+        return;
+      }
+      event.preventDefault();
+      const delta = event.deltaY > 0 ? -0.06 : 0.06;
+      setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value + delta)));
+    };
+    viewport.addEventListener("wheel", onWheel, { passive: false });
+    return () => viewport.removeEventListener("wheel", onWheel);
+  }, []);
+
+  const handleCanvasScroll = useCallback(() => {
+    const viewport = viewportRef.current;
+    if (!viewport) {
+      return;
+    }
+    setCanvasScrollLeft(viewport.scrollLeft);
+  }, []);
+
+  useEffect(() => {
+    const viewport = viewportRef.current;
+    if (!viewport) {
+      return;
+    }
+    handleCanvasScroll();
+    viewport.addEventListener("scroll", handleCanvasScroll, { passive: true });
+    return () => viewport.removeEventListener("scroll", handleCanvasScroll);
+  }, [handleCanvasScroll, layoutNodes.length, zoom, canvasWidth]);
+
+  useEffect(() => {
+    document.body.classList.toggle("cet-tree-fullscreen-active", treeFullscreen);
+    return () => {
+      document.body.classList.remove("cet-tree-fullscreen-active");
+    };
+  }, [treeFullscreen]);
+
+  useEffect(() => {
+    if (!treeFullscreen) {
+      return;
+    }
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        setTreeFullscreen(false);
+      }
+    };
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [treeFullscreen]);
+
+  const handleToggleTreeFullscreen = () => {
+    setTreeFullscreen((value) => !value);
+  };
+
+  const handleToggleCategory = (node: LayoutNode) => {
+    setActiveKey(node.key);
+    const category = node.category;
+    if (!category || !node.hasCategoryChildren) {
+      return;
+    }
+
+    const descendantIds = collectDescendantCategoryIds(
+      category.category_id,
+      childrenMap,
+    );
+    const willCollapse = expandedCategoryIds.has(category.category_id);
+
+    setExpandedCategoryIds((prev) => {
+      const next = new Set(prev);
+      if (willCollapse) {
+        next.delete(category.category_id);
+      } else {
+        next.add(category.category_id);
+      }
+      return next;
+    });
+
+    if (willCollapse) {
+      setExpandedElementParents((prev) => {
+        const next = new Set(prev);
+        for (const id of descendantIds) {
+          next.delete(id);
+        }
+        return next;
+      });
+    }
+  };
+
+  const handleToggleElements = (node: LayoutNode) => {
+    setActiveKey(node.key);
+    const category = node.category;
+    if (!category || !node.hasElements) {
+      return;
+    }
+
+    setExpandedElementParents((prev) => {
+      const next = new Set(prev);
+      if (next.has(category.category_id)) {
+        next.delete(category.category_id);
+      } else {
+        next.add(category.category_id);
+      }
+      return next;
+    });
+  };
+
+  const handleCollapseAll = () => {
+    setExpandedCategoryIds(new Set());
+    setExpandedElementParents(new Set());
+    setActiveKey(null);
+  };
+
+  const handleCollapseElements = () => {
+    setExpandedElementParents(new Set());
+  };
+
+  const disabledDate = (current: Dayjs) => {
+    const available = new Set(data?.available_dates ?? []);
+    if (available.size === 0) {
+      return false;
+    }
+    return !available.has(current.format("YYYYMMDD"));
+  };
+
+  const categoryRovScalesByLevel = useMemo(
+    () => buildCategoryRovScalesByLevel(data?.categories ?? [], categoryDepthMap),
+    [data?.categories, categoryDepthMap],
+  );
+  const elementRovScale = useMemo(
+    () => buildElementRovScale(data?.elements ?? []),
+    [data?.elements],
+  );
+
+  const invalidNodeCount = useMemo(() => {
+    if (!data) {
+      return 0;
+    }
+    const invalidCategories = data.categories.filter((item) => isZeroRovScore(item.rov_score)).length;
+    const invalidElements = data.elements.filter((item) => isZeroRovScore(item.rov_score)).length;
+    return invalidCategories + invalidElements;
+  }, [data]);
+
+  return (
+    <div className={`cet-app${treeFullscreen ? " cet-app--tree-fullscreen" : ""}`}>
+      <header className="cet-header">
+        <div className="cet-header-main">
+          <div>
+            <p className="cet-eyebrow">Demand Summary</p>
+            <h1 className="cet-title">需求汇总</h1>
+            <p className="cet-subtitle">
+              从左到右浏览 L1/L2/L3/... 分类层级;任意有元素的分类可点「N 元素」展开。
+            </p>
+          </div>
+        </div>
+
+        <div className="cet-toolbar">
+          <div className="cet-toolbar-group">
+            <span className="cet-toolbar-label">效果日期</span>
+            <DatePicker
+              value={selectedDate}
+              onChange={(value) => setSelectedDate(value)}
+              allowClear={false}
+              disabledDate={disabledDate}
+              format="YYYY-MM-DD"
+            />
+            <Button
+              type="primary"
+              icon={<ReloadOutlined />}
+              loading={loading}
+              onClick={() => void fetchTree(selectedDate?.format("YYYYMMDD"))}
+            >
+              查询
+            </Button>
+          </div>
+
+          <div className="cet-toolbar-group">
+            <span className="cet-toolbar-label">展开层级</span>
+            <Select<ExpandLevelFilter>
+              value={expandLevelFilter}
+              onChange={handleExpandLevelFilterChange}
+              disabled={!data}
+              style={{ width: 108 }}
+              options={expandLevelOptions}
+            />
+            <Button icon={<ShrinkOutlined />} onClick={handleCollapseAll} disabled={!data}>
+              全部收起
+            </Button>
+            <Button onClick={handleCollapseElements} disabled={!data || expandedElementParents.size === 0}>
+              收起元素
+            </Button>
+          </div>
+
+          <div className="cet-toolbar-group cet-filter-group">
+            <Checkbox
+              checked={showInvalidNodes}
+              onChange={(event) => setShowInvalidNodes(event.target.checked)}
+              disabled={!data}
+            >
+              展示无效节点
+            </Checkbox>
+            {!showInvalidNodes && invalidNodeCount > 0 ? (
+              <span className="cet-filter-hint">已隐藏 {invalidNodeCount} 个</span>
+            ) : null}
+          </div>
+
+          <div className="cet-legend cet-legend--scale">
+            <div className="cet-legend-bar" aria-label="ROV 色阶" />
+            <span className="cet-legend-tag cet-legend-tag--bad">差</span>
+            <span className="cet-legend-tag">正常</span>
+            <span className="cet-legend-tag cet-legend-tag--good">好</span>
+          </div>
+          <div className="cet-legend cet-legend--zero">
+            <span className="cet-legend-swatch cet-legend-swatch--zero" aria-hidden />
+            <span className="cet-legend-label">无效节点</span>
+          </div>
+        </div>
+
+        {appliedDt ? (
+          <div className="cet-stats">
+            <span>日期 {formatDtLabel(appliedDt)}</span>
+            <span>分类 {data?.categories.length ?? 0}</span>
+            <span>元素 {data?.elements.length ?? 0}</span>
+            <span>节点 {layoutNodes.length}</span>
+          </div>
+        ) : null}
+      </header>
+
+      {error ? (
+        <div className="cet-alert-wrap">
+          <Alert type="error" showIcon message={`请求失败: ${error}`} />
+        </div>
+      ) : null}
+
+      <main className="cet-stage">
+        <Spin spinning={loading} tip="加载中..." wrapperClassName="cet-stage-spin">
+          {layoutNodes.length === 0 && !loading ? (
+            <div className="cet-empty">暂无分类数据,请先同步垂直领域分类。</div>
+          ) : (
+            <div className="cet-tree-viewport">
+              <div className="cet-viewport-controls">
+                <Button
+                  icon={<ZoomOutOutlined />}
+                  onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value - 0.1))}
+                  disabled={!data}
+                  aria-label="缩小"
+                />
+                <span className="cet-zoom-value">{Math.round(zoom * 100)}%</span>
+                <Button
+                  icon={<ZoomInOutlined />}
+                  onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value + 0.1))}
+                  disabled={!data}
+                  aria-label="放大"
+                />
+                <Button icon={<CompressOutlined />} onClick={fitToView} disabled={!data}>
+                  全局视角
+                </Button>
+                <Tooltip title={treeFullscreen ? "退出全屏(Esc)" : "全屏展示树"}>
+                  <Button
+                    icon={treeFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
+                    onClick={handleToggleTreeFullscreen}
+                    disabled={!data}
+                    aria-label={treeFullscreen ? "退出全屏" : "全屏展示树"}
+                  >
+                    {treeFullscreen ? "退出全屏" : "全屏"}
+                  </Button>
+                </Tooltip>
+              </div>
+
+              <div
+                className="cet-level-headers-bar"
+                style={{ height: LEVEL_HEADER_HEIGHT * zoom }}
+              >
+                <div
+                  className="cet-level-headers-track"
+                  style={{
+                    width: canvasWidth * zoom,
+                    transform: `translateX(-${canvasScrollLeft}px)`,
+                  }}
+                >
+                  <div
+                    className="cet-level-headers"
+                    style={{
+                      width: canvasWidth,
+                      height: LEVEL_HEADER_HEIGHT,
+                      transform: `scale(${zoom})`,
+                      transformOrigin: "top left",
+                    }}
+                  >
+                    {levelHeaders.map((header) => (
+                      <div
+                        key={`level-${header.depth}-${header.label}`}
+                        className="cet-level-header"
+                        style={{ left: header.x, width: NODE_WIDTH }}
+                      >
+                        {header.label}
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              </div>
+
+              <div className="cet-canvas-wrap" ref={viewportRef}>
+                <div
+                  className="cet-canvas-scaler"
+                  style={{
+                    width: canvasWidth * zoom,
+                    height: canvasHeight * zoom,
+                  }}
+                >
+                  <div
+                    className="cet-canvas"
+                    style={{
+                      width: canvasWidth,
+                      height: canvasHeight,
+                      transform: `scale(${zoom})`,
+                    }}
+                  >
+                    <svg
+                      className="cet-lines"
+                      width={canvasWidth}
+                      height={canvasHeight}
+                      aria-hidden
+                    >
+                      {connectors.map((path, index) => (
+                        <path key={`${path}-${index}`} d={path} className="cet-line" />
+                      ))}
+                    </svg>
+
+                    <div className="cet-nodes">
+                      {layoutNodes.map((node) => (
+                        <TreeNodeCard
+                          key={node.key}
+                          node={node}
+                          categoryRovScalesByLevel={categoryRovScalesByLevel}
+                          elementRovScale={elementRovScale}
+                          categoryDepthMap={categoryDepthMap}
+                          active={activeKey === node.key}
+                          onToggleCategory={() => handleToggleCategory(node)}
+                          onToggleElements={() => handleToggleElements(node)}
+                        />
+                      ))}
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          )}
+        </Spin>
+      </main>
+    </div>
+  );
+}

+ 26 - 0
frontend/src/DemandNavBar.tsx

@@ -0,0 +1,26 @@
+import "./demandNav.css";
+
+type DemandNavBarProps = {
+  active: "dashboard" | "summary";
+};
+
+export default function DemandNavBar({ active }: DemandNavBarProps) {
+  return (
+    <nav className="demand-nav" aria-label="需求导航">
+      <div className="demand-nav-inner">
+        <a
+          href="/"
+          className={`demand-nav-tab${active === "dashboard" ? " demand-nav-tab--active" : ""}`}
+        >
+          需求池数据看板
+        </a>
+        <a
+          href="/category-tree.html"
+          className={`demand-nav-tab${active === "summary" ? " demand-nav-tab--active" : ""}`}
+        >
+          需求汇总
+        </a>
+      </div>
+    </nav>
+  );
+}

+ 15 - 0
frontend/src/category-tree-main.tsx

@@ -0,0 +1,15 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "antd/dist/reset.css";
+import CategoryEffectTreeApp from "./CategoryEffectTreeApp";
+import DemandNavBar from "./DemandNavBar";
+import "./categoryEffectTree.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+  <React.StrictMode>
+    <>
+      <DemandNavBar active="summary" />
+      <CategoryEffectTreeApp />
+    </>
+  </React.StrictMode>,
+);

+ 638 - 0
frontend/src/categoryEffectTree.css

@@ -0,0 +1,638 @@
+:root {
+  font-family: "PingFang SC", "Microsoft YaHei", "SF Pro Text", sans-serif;
+  color: #1f2937;
+}
+
+html {
+  height: 100%;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  height: 100%;
+  background:
+    radial-gradient(circle at 12% 8%, rgba(147, 197, 253, 0.28), transparent 34%),
+    radial-gradient(circle at 88% 0%, rgba(125, 211, 252, 0.22), transparent 30%),
+    linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+}
+
+#root {
+  min-height: 100vh;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.cet-app {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.cet-header {
+  position: relative;
+  flex-shrink: 0;
+  z-index: 20;
+  padding: 22px 28px 16px;
+  background: rgba(255, 255, 255, 0.82);
+  backdrop-filter: blur(14px);
+  border-bottom: 1px solid rgba(148, 163, 184, 0.22);
+  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
+}
+
+.cet-header-main {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.cet-eyebrow {
+  margin: 0 0 4px;
+  font-size: 12px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: #64748b;
+}
+
+.cet-title {
+  margin: 0;
+  font-size: 28px;
+  line-height: 1.2;
+  font-weight: 700;
+  background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 45%, #0ea5e9 100%);
+  -webkit-background-clip: text;
+  background-clip: text;
+  color: transparent;
+}
+
+.cet-subtitle {
+  margin: 8px 0 0;
+  color: #64748b;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.cet-back-link {
+  display: inline-flex;
+  align-items: center;
+  height: 36px;
+  padding: 0 14px;
+  border-radius: 999px;
+  text-decoration: none;
+  color: #1d4ed8;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  font-size: 13px;
+  font-weight: 600;
+  transition: background 0.15s ease, transform 0.15s ease;
+}
+
+.cet-back-link:hover {
+  background: #dbeafe;
+  transform: translateY(-1px);
+}
+
+.cet-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 14px 18px;
+  margin-top: 16px;
+}
+
+.cet-toolbar-group {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.cet-toolbar-label {
+  font-size: 13px;
+  color: #475569;
+  font-weight: 600;
+}
+
+.cet-legend {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+}
+
+.cet-legend--scale {
+  margin-left: auto;
+}
+
+.cet-legend--zero {
+  margin-left: 0;
+}
+
+.cet-legend-swatch {
+  width: 18px;
+  height: 10px;
+  border-radius: 999px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  flex-shrink: 0;
+}
+
+.cet-legend-swatch--zero {
+  background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
+  border-color: #94a3b8;
+}
+
+.cet-legend-label {
+  font-size: 12px;
+  color: #64748b;
+  font-weight: 600;
+}
+
+.cet-legend-bar {
+  width: 140px;
+  height: 10px;
+  border-radius: 999px;
+  background: linear-gradient(90deg, #fca5a5 0%, #ffffff 50%, #86efac 100%);
+  border: 1px solid rgba(15, 23, 42, 0.08);
+}
+
+.cet-legend-tag {
+  font-size: 11px;
+  color: #64748b;
+  font-weight: 600;
+}
+
+.cet-legend-tag--bad {
+  color: #dc2626;
+}
+
+.cet-legend-tag--good {
+  color: #16a34a;
+}
+
+.cet-legend-value {
+  font-size: 11px;
+  color: #64748b;
+  font-variant-numeric: tabular-nums;
+}
+
+.cet-stats {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.cet-stats span {
+  font-size: 12px;
+  color: #475569;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+  border-radius: 999px;
+  padding: 4px 10px;
+}
+
+.cet-alert-wrap {
+  padding: 0 28px;
+  margin-top: 12px;
+}
+
+.cet-stage {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  padding: 18px 20px 28px;
+}
+
+.cet-stage-spin,
+.cet-stage-spin > .ant-spin-container {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.cet-tree-viewport {
+  position: relative;
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.9);
+  border: 1px solid rgba(148, 163, 184, 0.25);
+  box-shadow:
+    0 24px 60px rgba(15, 23, 42, 0.06),
+    0 8px 20px rgba(15, 23, 42, 0.04);
+  overflow: hidden;
+}
+
+.cet-viewport-controls {
+  position: absolute;
+  top: 10px;
+  right: 12px;
+  z-index: 15;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 8px;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.94);
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
+  backdrop-filter: blur(8px);
+}
+
+.cet-viewport-controls .cet-zoom-value {
+  min-width: 44px;
+  padding: 0 2px;
+}
+
+.cet-app--tree-fullscreen {
+  position: fixed;
+  inset: 0;
+  z-index: 200;
+  background:
+    radial-gradient(circle at 12% 8%, rgba(147, 197, 253, 0.22), transparent 34%),
+    radial-gradient(circle at 88% 0%, rgba(125, 211, 252, 0.18), transparent 30%),
+    linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+}
+
+.cet-app--tree-fullscreen .cet-header,
+.cet-app--tree-fullscreen .cet-alert-wrap {
+  display: none;
+}
+
+.cet-app--tree-fullscreen .cet-stage {
+  padding: 0;
+}
+
+.cet-app--tree-fullscreen .cet-tree-viewport {
+  border-radius: 0;
+  border: none;
+  box-shadow: none;
+}
+
+body.cet-tree-fullscreen-active {
+  overflow: hidden;
+}
+
+body.cet-tree-fullscreen-active .demand-nav {
+  display: none;
+}
+
+.cet-level-headers-bar {
+  flex-shrink: 0;
+  overflow: hidden;
+  border-bottom: 1px dashed #dbe3ef;
+  background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(248, 250, 252, 0.92) 100%);
+  box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
+}
+
+.cet-level-headers-track {
+  will-change: transform;
+}
+
+.cet-filter-group {
+  padding: 4px 12px;
+  border-radius: 999px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+}
+
+.cet-filter-hint {
+  font-size: 11px;
+  color: #94a3b8;
+  font-variant-numeric: tabular-nums;
+}
+
+.cet-zoom-value {
+  min-width: 44px;
+  text-align: center;
+  font-size: 12px;
+  font-weight: 600;
+  color: #475569;
+  font-variant-numeric: tabular-nums;
+}
+
+.cet-canvas-wrap {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+}
+
+.cet-canvas-scaler {
+  position: relative;
+  min-width: min-content;
+  min-height: min-content;
+}
+
+.cet-empty {
+  flex: 1;
+  min-height: 360px;
+  display: grid;
+  place-items: center;
+  color: #94a3b8;
+  font-size: 15px;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.9);
+  border: 1px solid rgba(148, 163, 184, 0.25);
+}
+
+.cet-canvas {
+  position: relative;
+  min-width: 100%;
+  transform-origin: top left;
+}
+
+.cet-level-headers {
+  position: relative;
+  height: 44px;
+}
+
+.cet-level-header {
+  position: absolute;
+  top: 8px;
+  height: 28px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 8px;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  color: #1d4ed8;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+}
+
+.cet-lines {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+}
+
+.cet-line {
+  fill: none;
+  stroke: #cbd5e1;
+  stroke-width: 1.5;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+}
+
+.cet-nodes {
+  position: relative;
+  z-index: 1;
+}
+
+.cet-node {
+  position: absolute;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 0 6px 0 4px;
+  border-radius: 10px;
+  border: 1px solid transparent;
+  text-align: left;
+  box-shadow:
+    0 1px 2px rgba(15, 23, 42, 0.06),
+    0 6px 16px rgba(15, 23, 42, 0.05);
+  transition:
+    transform 0.14s ease,
+    box-shadow 0.14s ease,
+    border-color 0.14s ease;
+}
+
+.cet-node:hover {
+  transform: translateY(-1px);
+  box-shadow:
+    0 4px 10px rgba(15, 23, 42, 0.08),
+    0 10px 24px rgba(15, 23, 42, 0.1);
+}
+
+.cet-node-caret-btn,
+.cet-node-main,
+.cet-node-badge {
+  border: none;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+}
+
+.cet-node-caret-btn {
+  flex-shrink: 0;
+  width: 18px;
+  height: 28px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: #2563eb;
+  font-size: 11px;
+  border-radius: 6px;
+}
+
+.cet-node-caret-btn:hover {
+  background: rgba(37, 99, 235, 0.1);
+}
+
+.cet-node-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  padding: 4px 2px;
+  text-align: left;
+}
+
+.cet-node-title-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
+}
+
+.cet-node-kind-tag {
+  flex-shrink: 0;
+  padding: 0 5px;
+  border-radius: 999px;
+  font-size: 9px;
+  font-weight: 700;
+  line-height: 1.35;
+  letter-spacing: 0.02em;
+}
+
+.cet-node-kind-tag--category {
+  background: rgba(37, 99, 235, 0.14);
+  color: #1d4ed8;
+}
+
+.cet-node-kind-tag--element {
+  background: rgba(13, 148, 136, 0.16);
+  color: #0f766e;
+}
+
+.cet-node-badges {
+  display: inline-flex;
+  flex-direction: column;
+  gap: 3px;
+  flex-shrink: 0;
+}
+
+.cet-node--active {
+  box-shadow:
+    0 0 0 2px rgba(59, 130, 246, 0.28),
+    0 10px 24px rgba(37, 99, 235, 0.16);
+}
+
+.cet-node--element {
+  cursor: default;
+}
+
+.cet-node--leaf {
+  border-style: dashed;
+}
+
+.cet-node--zero {
+  border-style: dashed;
+}
+
+.cet-node-zero-tag {
+  margin-left: 4px;
+  padding: 0 5px;
+  border-radius: 999px;
+  background: rgba(148, 163, 184, 0.18);
+  color: #64748b;
+  font-size: 9px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+  vertical-align: middle;
+}
+
+.cet-tooltip-note {
+  color: #94a3b8;
+  font-size: 11px;
+}
+
+.cet-node-caret {
+  flex-shrink: 0;
+  width: 14px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: #2563eb;
+  font-size: 11px;
+}
+
+.cet-node-caret--placeholder {
+  opacity: 0;
+}
+
+.cet-node-label {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.2;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.cet-node-score {
+  font-size: 11px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+  opacity: 0.88;
+}
+
+.cet-node-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  height: 20px;
+  padding: 0 7px;
+  border-radius: 999px;
+  font-size: 10px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.cet-node-badge .anticon {
+  font-size: 8px;
+  opacity: 0.75;
+}
+
+.cet-node-badge--category {
+  background: rgba(71, 85, 105, 0.12);
+  color: #475569;
+}
+
+.cet-node-badge--category:hover {
+  background: rgba(71, 85, 105, 0.2);
+}
+
+.cet-node-badge--element {
+  background: rgba(37, 99, 235, 0.14);
+  color: #1d4ed8;
+}
+
+.cet-node-badge--element:hover {
+  background: rgba(37, 99, 235, 0.24);
+}
+
+.cet-node-badge--expanded {
+  background: rgba(37, 99, 235, 0.24);
+  box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.35);
+}
+
+.cet-tooltip {
+  display: grid;
+  gap: 2px;
+  font-size: 12px;
+  line-height: 1.45;
+}
+
+@media (max-width: 900px) {
+  .cet-header {
+    padding: 16px;
+  }
+
+  .cet-header-main {
+    flex-direction: column;
+  }
+
+  .cet-title {
+    font-size: 24px;
+  }
+
+  .cet-legend {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .cet-legend--scale {
+    margin-left: 0;
+  }
+
+  .cet-legend--zero {
+    margin-left: 0;
+  }
+
+  .cet-stage {
+    padding: 12px;
+  }
+}

+ 64 - 0
frontend/src/demandNav.css

@@ -0,0 +1,64 @@
+:root {
+  --demand-nav-height: 52px;
+}
+
+.demand-nav {
+  position: sticky;
+  top: 0;
+  z-index: 30;
+  height: var(--demand-nav-height);
+  display: flex;
+  align-items: center;
+  padding: 0 24px;
+  background: rgba(255, 255, 255, 0.92);
+  backdrop-filter: blur(12px);
+  border-bottom: 1px solid rgba(148, 163, 184, 0.28);
+  box-shadow: 0 4px 18px rgba(15, 23, 42, 0.04);
+}
+
+.demand-nav-inner {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px;
+  border-radius: 999px;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+}
+
+.demand-nav-tab {
+  display: inline-flex;
+  align-items: center;
+  height: 34px;
+  padding: 0 16px;
+  border-radius: 999px;
+  text-decoration: none;
+  font-size: 14px;
+  font-weight: 600;
+  color: #475569;
+  transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.demand-nav-tab:hover {
+  color: #1d4ed8;
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.demand-nav-tab--active {
+  color: #1d4ed8;
+  background: #ffffff;
+  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
+}
+
+@media (max-width: 900px) {
+  .demand-nav {
+    padding: 0 12px;
+    overflow-x: auto;
+  }
+
+  .demand-nav-tab {
+    white-space: nowrap;
+    font-size: 13px;
+    padding: 0 12px;
+  }
+}

+ 17 - 0
frontend/src/styles.css

@@ -68,6 +68,23 @@ body {
   gap: 8px;
   flex-wrap: wrap;
   margin-top: 4px;
+  align-items: center;
+}
+
+.hero-external-link {
+  margin-left: 4px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #1d4ed8;
+  text-decoration: none;
+  padding: 2px 10px;
+  border-radius: 999px;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+}
+
+.hero-external-link:hover {
+  background: #dbeafe;
 }
 
 /* 外层白盒:圆角+阴影只在这一层,彻底规避 Ant Tabs 主题自带的边框线 */

+ 9 - 0
frontend/vite.config.ts

@@ -1,8 +1,17 @@
+import { resolve } from "path";
 import { defineConfig } from "vite";
 import react from "@vitejs/plugin-react";
 
 export default defineConfig({
   plugins: [react()],
+  build: {
+    rollupOptions: {
+      input: {
+        main: resolve(__dirname, "index.html"),
+        categoryTree: resolve(__dirname, "category-tree.html"),
+      },
+    },
+  },
   server: {
     host: "0.0.0.0",
     port: 5173,