Parcourir la source

Merge branch 'feature/zhangbo' into dev-web

xueyiming il y a 2 semaines
Parent
commit
affce5b2ff

+ 58 - 0
api/services/demand_videos.py

@@ -0,0 +1,58 @@
+"""Resolve demand_belong_category.video_list → multi_demand_video_detail."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+
+def _parse_video_ids(raw: str | None) -> list[str]:
+    if not raw:
+        return []
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(parsed, list):
+        return []
+    return [str(v).strip() for v in parsed if v is not None and str(v).strip()]
+
+
+def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
+    """
+    按 demand_belong_category.id 返回关联视频详情。
+
+    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/final_topic_json 为空。
+    """
+    with get_session() as session:
+        belong = DemandBelongCategoryRepository(session).get_by_id(belong_id)
+        if belong is None or belong.is_delete:
+            return None
+
+        vids = _parse_video_ids(belong.video_list)
+        details = MultiDemandVideoDetailRepository(session).list_by_vids(vids)
+
+        videos: list[dict[str, Any]] = []
+        for vid in vids:
+            row = details.get(vid)
+            videos.append(
+                {
+                    "vid": vid,
+                    "title": row.title if row else None,
+                    "final_topic_json": row.final_topic_json if row else None,
+                }
+            )
+
+        return {
+            "demand_belong_id": belong.id,
+            "name": belong.name,
+            "category_id": belong.category_id,
+            "videos": videos,
+        }

+ 35 - 0
jobs/backfill_multi_demand_video_list.py

@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_pool_di 的 video_list / video_count。
+
+用法:
+    python jobs/backfill_multi_demand_video_list.py 20260714
+    python jobs/backfill_multi_demand_video_list.py          # 默认当天
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from datetime import datetime
+
+from supply_infra.scheduler.jobs.sync_multi_demand_pool_odps_to_mysql import (
+    backfill_video_list,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(biz_dt: str | None = None) -> dict:
+    if biz_dt is None:
+        biz_dt = datetime.now().strftime("%Y%m%d")
+    result = backfill_video_list(biz_dt)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
+    main(date_arg)

+ 33 - 0
jobs/backfill_multi_demand_video_titles.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail.title(decode_result.target_post.title)。
+
+用法:
+    python jobs/backfill_multi_demand_video_titles.py
+    python jobs/backfill_multi_demand_video_titles.py 100   # 每批 100
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_video_titles,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(batch_arg: str | None = None) -> dict:
+    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
+    result = backfill_video_titles(batch_size=batch_size)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 29 - 0
jobs/sync_demand_belong_pool_rel.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""手动同步 demand_belong_pool_rel,并回填 demand_belong_category.video_list。
+
+用法:
+    python jobs/sync_demand_belong_pool_rel.py
+"""
+
+from __future__ import annotations
+
+import logging
+
+from supply_infra.scheduler.jobs.sync_demand_belong_pool_rel import (
+    sync_demand_belong_pool_rel,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main() -> dict:
+    result = sync_demand_belong_pool_rel()
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main()

+ 57 - 0
jobs/sync_multi_demand_videos.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""手动增量同步 multi_demand_video_detail(每批查询后立刻写入)。
+
+用法:
+    python jobs/sync_multi_demand_videos.py              # 只跑 1 批 100 个
+    python jobs/sync_multi_demand_videos.py 100           # 同上
+    python jobs/sync_multi_demand_videos.py 100 100       # 从 offset=100 起再跑 1 批
+    python jobs/sync_multi_demand_videos.py all           # 循环每批 100,查完写一批直到结束
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    sync_multi_demand_videos,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(limit_arg: str | None = None, offset_arg: str | None = None) -> dict:
+    if limit_arg is None:
+        limit: int | None = VIDEO_SYNC_BATCH_SIZE
+    elif limit_arg.lower() in {"all", "0", "-1"}:
+        limit = None
+    else:
+        limit = int(limit_arg)
+
+    offset = int(offset_arg) if offset_arg is not None else 0
+    result = sync_multi_demand_videos(
+        limit=limit,
+        offset=offset,
+        batch_size=VIDEO_SYNC_BATCH_SIZE,
+    )
+    print(result)
+
+    remaining = int(result.get("remaining") or 0)
+    next_offset = result.get("next_offset")
+    if remaining > 0 and next_offset is not None and limit is not None:
+        print(
+            f"还有 {remaining} 个未处理。下一批:\n"
+            f"  python jobs/sync_multi_demand_videos.py {limit} {next_offset}"
+        )
+    return result
+
+
+if __name__ == "__main__":
+    main(
+        sys.argv[1] if len(sys.argv) > 1 else None,
+        sys.argv[2] if len(sys.argv) > 2 else None,
+    )

+ 42 - 0
supply_infra/db/models/demand_belong_pool_rel.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, Index, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class DemandBelongPoolRel(Base):
+    """需求归属词与需求池行的匹配关系。"""
+
+    __tablename__ = "demand_belong_pool_rel"
+    __table_args__ = (
+        UniqueConstraint(
+            "demand_belong_category_id",
+            "multi_demand_pool_di_id",
+            name="uk_belong_pool",
+        ),
+        Index("idx_belong_category_id", "demand_belong_category_id"),
+        Index("idx_pool_di_id", "multi_demand_pool_di_id"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    demand_belong_category_id: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, comment="demand_belong_category.id"
+    )
+    multi_demand_pool_di_id: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, comment="multi_demand_pool_di.id"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 35 - 0
supply_infra/db/models/multi_demand_video_detail.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class MultiDemandVideoDetail(Base):
+    """需求池关联视频详情表 — 存 vid 与 ODPS 最终选题 JSON。"""
+
+    __tablename__ = "multi_demand_video_detail"
+    __table_args__ = (UniqueConstraint("vid", name="uk_multi_demand_video_detail_vid"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    vid: Mapped[str] = mapped_column(String(64), nullable=False, comment="视频id")
+    title: Mapped[str | None] = mapped_column(
+        String(512), nullable=True, comment="视频标题(decode_result.target_post.title)"
+    )
+    final_topic_json: Mapped[str] = mapped_column(
+        Text, nullable=False, comment="最终选题JSON"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 55 - 0
supply_infra/db/repositories/demand_belong_pool_rel_repo.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from sqlalchemy import select, tuple_
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 1000
+
+RelPair = tuple[int, int]
+
+
+class DemandBelongPoolRelRepository(BaseRepository[DemandBelongPoolRel]):
+    """需求词 ↔ 需求池匹配边 — 按 (belong_id, pool_id) 增量写入。"""
+
+    model = DemandBelongPoolRel
+
+    def get_existing_pairs(self, pairs: Iterable[RelPair]) -> set[RelPair]:
+        """返回 pairs 中已存在的 (belong_id, pool_id)。"""
+        pair_list = [(int(b), int(p)) for b, p in pairs]
+        if not pair_list:
+            return set()
+
+        existing: set[RelPair] = set()
+        for i in range(0, len(pair_list), _BATCH_SIZE):
+            batch = pair_list[i : i + _BATCH_SIZE]
+            stmt = select(
+                DemandBelongPoolRel.demand_belong_category_id,
+                DemandBelongPoolRel.multi_demand_pool_di_id,
+            ).where(
+                tuple_(
+                    DemandBelongPoolRel.demand_belong_category_id,
+                    DemandBelongPoolRel.multi_demand_pool_di_id,
+                ).in_(batch)
+            )
+            existing.update(
+                (int(b), int(p)) for b, p in self.session.execute(stmt).all()
+            )
+        return existing
+
+    def bulk_insert_ignore(self, rows: list[dict]) -> int:
+        """批量插入,忽略已存在的唯一键。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(DemandBelongPoolRel).values(batch).prefix_with("IGNORE")
+            result = self.session.execute(stmt)
+            inserted += result.rowcount
+        return inserted

+ 85 - 0
supply_infra/db/repositories/multi_demand_video_detail_repo.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from sqlalchemy import select, update
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 1000
+
+
+class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
+    """需求池视频详情表 — 按 vid 唯一键增量写入。"""
+
+    model = MultiDemandVideoDetail
+
+    def get_existing_vids(self, vids: Iterable[str]) -> set[str]:
+        """返回 vids 中已存在于表内的视频 id。"""
+        vid_list = [v for v in vids if v]
+        if not vid_list:
+            return set()
+
+        existing: set[str] = set()
+        for i in range(0, len(vid_list), _BATCH_SIZE):
+            batch = vid_list[i : i + _BATCH_SIZE]
+            stmt = select(MultiDemandVideoDetail.vid).where(
+                MultiDemandVideoDetail.vid.in_(batch)
+            )
+            existing.update(str(v) for v in self.session.scalars(stmt).all() if v)
+        return existing
+
+    def list_by_vids(self, vids: Iterable[str]) -> dict[str, MultiDemandVideoDetail]:
+        """按 vid 批量查询,返回 vid → row。"""
+        vid_list = [v for v in vids if v]
+        if not vid_list:
+            return {}
+
+        result: dict[str, MultiDemandVideoDetail] = {}
+        for i in range(0, len(vid_list), _BATCH_SIZE):
+            batch = vid_list[i : i + _BATCH_SIZE]
+            stmt = select(MultiDemandVideoDetail).where(
+                MultiDemandVideoDetail.vid.in_(batch)
+            )
+            for row in self.session.scalars(stmt).all():
+                if row.vid:
+                    result[str(row.vid)] = row
+        return result
+
+    def list_vids_missing_title(self) -> list[str]:
+        """返回 title 为空的全部 vid。"""
+        stmt = select(MultiDemandVideoDetail.vid).where(
+            (MultiDemandVideoDetail.title.is_(None)) | (MultiDemandVideoDetail.title == "")
+        )
+        return [str(v) for v in self.session.scalars(stmt).all() if v]
+
+    def bulk_insert_ignore(self, rows: list[dict]) -> int:
+        """批量插入,MySQL 自动忽略 vid 重复行。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(MultiDemandVideoDetail).values(batch).prefix_with("IGNORE")
+            result = self.session.execute(stmt)
+            inserted += result.rowcount
+        return inserted
+
+    def update_titles(self, titles_by_vid: dict[str, str]) -> int:
+        """按 vid 批量更新 title。"""
+        if not titles_by_vid:
+            return 0
+
+        updated = 0
+        for vid, title in titles_by_vid.items():
+            stmt = (
+                update(MultiDemandVideoDetail)
+                .where(MultiDemandVideoDetail.vid == vid)
+                .values(title=title)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 130 - 0
supply_infra/scheduler/jobs/sync_demand_belong_pool_rel.py

@@ -0,0 +1,130 @@
+"""
+补充 demand_belong_category 与 multi_demand_pool_di 的匹配关系,
+并回填词级 video_list(匹配池视频去重截断最多 10 个)。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
+    DemandBelongPoolRelRepository,
+)
+from supply_infra.db.repositories.multi_demand_pool_di_repo import (
+    MultiDemandPoolDiRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+_VIDEO_LIST_LIMIT = 10
+
+
+def _parse_video_ids(raw: str | None) -> list[str]:
+    if not raw:
+        return []
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(parsed, list):
+        return []
+    return [str(v).strip() for v in parsed if v is not None and str(v).strip()]
+
+
+def _merge_video_ids(video_lists: list[str | None], limit: int = _VIDEO_LIST_LIMIT) -> str | None:
+    """按出现顺序去重,截断到 limit。"""
+    seen: set[str] = set()
+    ordered: list[str] = []
+    for raw in video_lists:
+        for vid in _parse_video_ids(raw):
+            if vid in seen:
+                continue
+            seen.add(vid)
+            ordered.append(vid)
+            if len(ordered) >= limit:
+                return json.dumps(ordered, ensure_ascii=False)
+    if not ordered:
+        return None
+    return json.dumps(ordered, ensure_ascii=False)
+
+
+def sync_demand_belong_pool_rel() -> dict[str, Any]:
+    """
+    增量建立词 ↔ 需求池匹配边,并更新词级 video_list。
+
+    - name 是 demand_name 子串则建边
+    - 已有边跳过;只插缺失
+    - video_list:匹配行视频按顺序去重,最多 10 个
+    """
+    logger.info("Starting demand_belong_pool_rel sync")
+
+    with get_session() as session:
+        words = DemandBelongCategoryRepository(session).list_active_id_name()
+        pool_rows = MultiDemandPoolDiRepository(session).list_id_name_video_lists()
+
+    logger.info("Loaded words=%d pool_rows=%d", len(words), len(pool_rows))
+
+    if not words or not pool_rows:
+        result = {
+            "words": len(words),
+            "pool_rows": len(pool_rows),
+            "matched_edges": 0,
+            "inserted": 0,
+            "video_updated": 0,
+        }
+        logger.info("Nothing to sync: %s", result)
+        return result
+
+    candidate_pairs: list[tuple[int, int]] = []
+    video_updates: dict[int, str | None] = {}
+    matched_edges = 0
+
+    for belong_id, name in words:
+        matched_pool_ids: list[int] = []
+        matched_video_lists: list[str | None] = []
+        for pool_id, demand_name, video_list in pool_rows:
+            if name in demand_name:
+                matched_pool_ids.append(pool_id)
+                matched_video_lists.append(video_list)
+
+        if not matched_pool_ids:
+            video_updates[belong_id] = None
+            continue
+
+        matched_edges += len(matched_pool_ids)
+        for pool_id in matched_pool_ids:
+            candidate_pairs.append((belong_id, pool_id))
+        video_updates[belong_id] = _merge_video_ids(matched_video_lists)
+
+    with get_session() as session:
+        rel_repo = DemandBelongPoolRelRepository(session)
+        existing = rel_repo.get_existing_pairs(candidate_pairs)
+        insert_rows = [
+            {
+                "demand_belong_category_id": belong_id,
+                "multi_demand_pool_di_id": pool_id,
+            }
+            for belong_id, pool_id in candidate_pairs
+            if (belong_id, pool_id) not in existing
+        ]
+        inserted = rel_repo.bulk_insert_ignore(insert_rows)
+        video_updated = DemandBelongCategoryRepository(session).update_video_lists(
+            video_updates
+        )
+
+    result = {
+        "words": len(words),
+        "pool_rows": len(pool_rows),
+        "matched_edges": matched_edges,
+        "candidate_pairs": len(candidate_pairs),
+        "existing_pairs": len(existing),
+        "inserted": inserted,
+        "video_updated": video_updated,
+    }
+    logger.info("demand_belong_pool_rel sync completed: %s", result)
+    return result

+ 346 - 0
supply_infra/scheduler/jobs/sync_multi_demand_videos.py

@@ -0,0 +1,346 @@
+"""
+从 multi_demand_pool_di 收集视频,增量写入 multi_demand_video_detail,
+并从 ODPS 昨天分区拉取最终选题 JSON 与标题。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Any
+
+from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+from supply_infra.odps.client import ODPSClient, get_odps_client
+
+logger = logging.getLogger(__name__)
+
+_FINAL_TOPIC_KEY = "最终选题"
+_TARGET_POST_KEY = "target_post"
+_TITLE_KEY = "title"
+VIDEO_SYNC_BATCH_SIZE = 100
+
+
+def _collect_vids_from_video_lists(video_lists: list[str]) -> set[str]:
+    """解析 video_list JSON 文本,收集去重 vid。"""
+    vids: set[str] = set()
+    for text in video_lists:
+        try:
+            parsed = json.loads(text)
+        except json.JSONDecodeError:
+            logger.warning("Skip invalid video_list JSON: %s", text[:120])
+            continue
+        if not isinstance(parsed, list):
+            logger.warning("Skip non-list video_list: %s", text[:120])
+            continue
+        for item in parsed:
+            if item is None:
+                continue
+            vid = str(item).strip()
+            if vid:
+                vids.add(vid)
+    return vids
+
+
+def _parse_decode_result(decode_result: Any) -> dict[str, Any] | None:
+    """将 decode_result 解析为 dict。"""
+    if decode_result is None:
+        return None
+
+    if isinstance(decode_result, dict):
+        return decode_result
+
+    if isinstance(decode_result, str):
+        text = decode_result.strip()
+        if not text:
+            return None
+        try:
+            payload = json.loads(text)
+        except json.JSONDecodeError:
+            logger.warning("Skip invalid decode_result JSON: %s", text[:120])
+            return None
+        return payload if isinstance(payload, dict) else None
+
+    return None
+
+
+def _extract_final_topic_json(payload: dict[str, Any]) -> str | None:
+    """从 decode_result 取出「最终选题」并序列化为 JSON 文本。"""
+    final_topic = payload.get(_FINAL_TOPIC_KEY)
+    if final_topic is None:
+        return None
+    return json.dumps(final_topic, ensure_ascii=False)
+
+
+def _extract_title(payload: dict[str, Any]) -> str | None:
+    """从 decode_result.target_post.title 取标题。"""
+    target_post = payload.get(_TARGET_POST_KEY)
+    if not isinstance(target_post, dict):
+        return None
+    title = target_post.get(_TITLE_KEY)
+    if title is None:
+        return None
+    text = str(title).strip()
+    return text[:512] if text else None
+
+
+def _sync_one_batch(
+    odps: ODPSClient,
+    decode_dt: str,
+    batch_vids: list[str],
+    batch_idx: int,
+    batch_total: int,
+) -> dict[str, int]:
+    """查询一批 vid,立刻写入 MySQL。"""
+    logger.info(
+        "Batch %d/%d: query %d vids, decode_dt=%s",
+        batch_idx,
+        batch_total,
+        len(batch_vids),
+        decode_dt,
+    )
+    odps_rows = odps.fetch_topic_decode_results(
+        decode_dt, batch_vids, batch_size=len(batch_vids)
+    )
+
+    insert_rows: list[dict[str, Any]] = []
+    skipped_no_topic = 0
+    seen_vids: set[str] = set()
+    for row in odps_rows:
+        raw_vid = row.get("vid")
+        if raw_vid is None:
+            continue
+        vid = str(raw_vid).strip()
+        if not vid or vid in seen_vids:
+            continue
+        payload = _parse_decode_result(row.get("decode_result"))
+        if payload is None:
+            skipped_no_topic += 1
+            continue
+        topic_json = _extract_final_topic_json(payload)
+        if topic_json is None:
+            skipped_no_topic += 1
+            continue
+        seen_vids.add(vid)
+        insert_rows.append(
+            {
+                "vid": vid,
+                "title": _extract_title(payload),
+                "final_topic_json": topic_json,
+            }
+        )
+
+    with get_session() as session:
+        inserted = MultiDemandVideoDetailRepository(session).bulk_insert_ignore(
+            insert_rows
+        )
+
+    odps_vids = {
+        str(r.get("vid")).strip()
+        for r in odps_rows
+        if r.get("vid") is not None and str(r.get("vid")).strip()
+    }
+    missing_in_odps = len(set(batch_vids) - odps_vids)
+    stats = {
+        "odps_rows": len(odps_rows),
+        "inserted": inserted,
+        "skipped_no_topic": skipped_no_topic,
+        "missing_in_odps": missing_in_odps,
+    }
+    logger.info("Batch %d/%d done: %s", batch_idx, batch_total, stats)
+    return stats
+
+
+def sync_multi_demand_videos(
+    limit: int | None = None,
+    offset: int = 0,
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+) -> dict[str, Any]:
+    """
+    增量同步需求池视频与最终选题。
+
+    - 视频来源:multi_demand_pool_di 全表 video_list
+    - 已有 vid 跳过
+    - ODPS decode 分区永远用「今天的昨天」
+    - 按 batch_size 分批:每批查询后立刻写入
+    - limit: 本轮最多处理的 pending 数;None 表示从 offset 起全部
+    - offset: pending 列表起始偏移(手动分批用)
+    """
+    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    start = max(0, int(offset))
+    chunk = max(1, int(batch_size))
+    logger.info(
+        "Starting multi demand video sync: decode_dt=%s limit=%s offset=%d batch_size=%d",
+        decode_dt,
+        limit,
+        start,
+        chunk,
+    )
+
+    with get_session() as session:
+        video_lists = MultiDemandPoolDiRepository(session).list_all_video_lists()
+        all_vids = _collect_vids_from_video_lists(video_lists)
+        existing = (
+            MultiDemandVideoDetailRepository(session).get_existing_vids(all_vids)
+            if all_vids
+            else set()
+        )
+
+    pending_all = sorted(all_vids - existing)
+    pending = pending_all[start:]
+    if limit is not None:
+        pending = pending[: max(0, int(limit))]
+
+    logger.info(
+        "Vids: pool_lists=%d unique=%d existing=%d pending_total=%d to_process=%d",
+        len(video_lists),
+        len(all_vids),
+        len(existing),
+        len(pending_all),
+        len(pending),
+    )
+
+    empty = {
+        "decode_dt": decode_dt,
+        "pool_video_lists": len(video_lists),
+        "unique_vids": len(all_vids),
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": 0,
+        "processed": 0,
+        "odps_rows": 0,
+        "inserted": 0,
+        "skipped_no_topic": 0,
+        "missing_in_odps": 0,
+        "next_offset": start,
+        "remaining": max(0, len(pending_all) - start),
+    }
+    if not pending:
+        logger.info("No pending vids: %s", empty)
+        return empty
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_odps_rows = 0
+    total_inserted = 0
+    total_skipped = 0
+    total_missing = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        stats = _sync_one_batch(odps, decode_dt, batch_vids, idx, len(batches))
+        total_odps_rows += stats["odps_rows"]
+        total_inserted += stats["inserted"]
+        total_skipped += stats["skipped_no_topic"]
+        total_missing += stats["missing_in_odps"]
+
+    next_offset = start + len(pending)
+    result = {
+        "decode_dt": decode_dt,
+        "pool_video_lists": len(video_lists),
+        "unique_vids": len(all_vids),
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": len(batches),
+        "processed": len(pending),
+        "odps_rows": total_odps_rows,
+        "inserted": total_inserted,
+        "skipped_no_topic": total_skipped,
+        "missing_in_odps": total_missing,
+        "next_offset": next_offset,
+        "remaining": max(0, len(pending_all) - next_offset),
+    }
+    logger.info("Multi demand video sync completed: %s", result)
+    return result
+
+
+def backfill_video_titles(
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+) -> dict[str, Any]:
+    """为 title 为空的已有行,从 ODPS 昨天分区回填 target_post.title。"""
+    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    chunk = max(1, int(batch_size))
+    logger.info("Backfill video titles: decode_dt=%s batch_size=%d", decode_dt, chunk)
+
+    with get_session() as session:
+        pending = MultiDemandVideoDetailRepository(session).list_vids_missing_title()
+
+    if not pending:
+        result = {
+            "decode_dt": decode_dt,
+            "pending": 0,
+            "batches": 0,
+            "updated": 0,
+            "missing_in_odps": 0,
+            "skipped_no_title": 0,
+        }
+        logger.info("No vids missing title: %s", result)
+        return result
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_updated = 0
+    total_missing = 0
+    total_skipped = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        logger.info(
+            "Title backfill batch %d/%d: %d vids",
+            idx,
+            len(batches),
+            len(batch_vids),
+        )
+        odps_rows = odps.fetch_topic_decode_results(
+            decode_dt, batch_vids, batch_size=len(batch_vids)
+        )
+        titles_by_vid: dict[str, str] = {}
+        for row in odps_rows:
+            raw_vid = row.get("vid")
+            if raw_vid is None:
+                continue
+            vid = str(raw_vid).strip()
+            if not vid or vid in titles_by_vid:
+                continue
+            payload = _parse_decode_result(row.get("decode_result"))
+            if payload is None:
+                total_skipped += 1
+                continue
+            title = _extract_title(payload)
+            if title is None:
+                total_skipped += 1
+                continue
+            titles_by_vid[vid] = title
+
+        with get_session() as session:
+            updated = MultiDemandVideoDetailRepository(session).update_titles(
+                titles_by_vid
+            )
+        total_updated += updated
+
+        odps_vids = {
+            str(r.get("vid")).strip()
+            for r in odps_rows
+            if r.get("vid") is not None and str(r.get("vid")).strip()
+        }
+        total_missing += len(set(batch_vids) - odps_vids)
+        logger.info(
+            "Title backfill batch %d/%d done: updated=%d",
+            idx,
+            len(batches),
+            updated,
+        )
+
+    result = {
+        "decode_dt": decode_dt,
+        "pending": len(pending),
+        "batches": len(batches),
+        "updated": total_updated,
+        "missing_in_odps": total_missing,
+        "skipped_no_title": total_skipped,
+    }
+    logger.info("Backfill video titles completed: %s", result)
+    return result

+ 400 - 0
web/src/components/DemandPathPanel.vue

@@ -0,0 +1,400 @@
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import { fetchDemandBelongVideos } from '../api/demand'
+import type { DemandBelongItem, DemandVideoItem } from '../types/demand'
+
+const props = defineProps<{
+  open: boolean
+  categoryName: string
+  items: DemandBelongItem[]
+}>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+
+const rootRef = ref<HTMLElement | null>(null)
+const selectedDemandId = ref<number | null>(null)
+const videos = ref<DemandVideoItem[]>([])
+const videosLoading = ref(false)
+const videosError = ref<string | null>(null)
+const selectedVid = ref<string | null>(null)
+
+const selectedDemand = computed(
+  () => props.items.find((item) => item.id === selectedDemandId.value) ?? null,
+)
+
+const selectedVideo = computed(
+  () => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
+)
+
+const topicDisplay = computed(() => {
+  const raw = selectedVideo.value?.final_topic_json
+  if (!raw) return null
+  try {
+    return JSON.stringify(JSON.parse(raw), null, 2)
+  } catch {
+    return raw
+  }
+})
+
+watch(
+  () => [props.open, props.categoryName] as const,
+  async ([open]) => {
+    selectedDemandId.value = null
+    videos.value = []
+    videosError.value = null
+    selectedVid.value = null
+    if (open) {
+      await nextTick()
+      rootRef.value?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' })
+    }
+  },
+)
+
+async function selectDemand(item: DemandBelongItem) {
+  if (selectedDemandId.value === item.id) return
+  selectedDemandId.value = item.id
+  selectedVid.value = null
+  videos.value = []
+  videosError.value = null
+  videosLoading.value = true
+  try {
+    const res = await fetchDemandBelongVideos(item.id)
+    videos.value = res.videos ?? []
+  } catch (e) {
+    videosError.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    videosLoading.value = false
+  }
+}
+
+function selectVideo(video: DemandVideoItem) {
+  selectedVid.value = video.vid
+}
+</script>
+
+<template>
+  <div v-if="open" ref="rootRef" class="path-rail" role="region" :aria-label="`${categoryName} 路径展开`">
+    <!-- 节点 → 需求词 -->
+    <div class="arrow-col" aria-hidden="true">
+      <div class="arrow arrow-blue">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-demand">
+      <div class="col-head">
+        <h3 class="col-title">细节元素 · 需求词</h3>
+        <button type="button" class="close-btn" title="收起路径" @click="emit('close')">×</button>
+      </div>
+      <div v-if="items.length" class="card-list">
+        <button
+          v-for="item in items"
+          :key="item.id"
+          type="button"
+          class="card demand-card"
+          :class="{ active: selectedDemandId === item.id }"
+          @click="selectDemand(item)"
+        >
+          <span class="card-label">需求词</span>
+          <span class="card-value">{{ item.name || '—' }}</span>
+        </button>
+      </div>
+      <div v-else class="empty">暂无需求词</div>
+    </section>
+
+    <!-- 需求词 → 视频 -->
+    <div class="arrow-col" aria-hidden="true">
+      <div v-if="selectedDemand" class="arrow arrow-green">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-video">
+      <h3 class="col-title">真实视频实例</h3>
+      <template v-if="selectedDemand">
+        <div v-if="videosLoading" class="empty">加载中…</div>
+        <div v-else-if="videosError" class="empty error">{{ videosError }}</div>
+        <div v-else-if="videos.length" class="card-list">
+          <button
+            v-for="video in videos"
+            :key="video.vid"
+            type="button"
+            class="card video-card"
+            :class="{ active: selectedVid === video.vid }"
+            @click="selectVideo(video)"
+          >
+            <span class="card-label">Video {{ video.vid }}</span>
+            <span class="card-value">{{ video.title || '(无标题)' }}</span>
+          </button>
+        </div>
+        <div v-else class="empty">暂无关联视频</div>
+      </template>
+      <div v-else class="empty hint">点击需求词展开</div>
+    </section>
+
+    <!-- 视频 → JSON -->
+    <div class="arrow-col" aria-hidden="true">
+      <div v-if="selectedVideo" class="arrow arrow-purple">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-topic">
+      <h3 class="col-title">语言提取 · final_topic_json</h3>
+      <template v-if="selectedVideo">
+        <div v-if="topicDisplay" class="topic-card">
+          <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
+          <pre class="topic-json">{{ topicDisplay }}</pre>
+        </div>
+        <div v-else class="empty">暂无 final_topic_json</div>
+      </template>
+      <div v-else class="empty hint">点击视频展开</div>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.path-rail {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  gap: 0;
+  padding-left: 0;
+  min-height: 80px;
+}
+
+.col {
+  width: 220px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.col-topic {
+  width: 300px;
+}
+
+.col-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 6px;
+}
+
+.col-title {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.03em;
+  color: #64748b;
+}
+
+.col-demand .col-title {
+  color: #1d4ed8;
+}
+
+.col-video .col-title {
+  color: #15803d;
+}
+
+.col-topic .col-title {
+  color: #7e22ce;
+}
+
+.close-btn {
+  width: 22px;
+  height: 22px;
+  border: none;
+  border-radius: 4px;
+  background: transparent;
+  color: #94a3b8;
+  font-size: 16px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+  padding: 0;
+}
+
+.close-btn:hover {
+  background: #f1f5f9;
+  color: #0f172a;
+}
+
+.card-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 420px;
+  overflow: auto;
+  padding-right: 2px;
+}
+
+.card {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+  width: 100%;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: 1.5px solid transparent;
+  background: #fff;
+  text-align: left;
+  cursor: pointer;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.card:hover {
+  box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
+}
+
+.card-label {
+  font-size: 11px;
+  font-weight: 600;
+  opacity: 0.8;
+}
+
+.card-value {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.35;
+  word-break: break-word;
+  color: #0f172a;
+}
+
+.demand-card {
+  border-color: #93c5fd;
+  background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
+}
+
+.demand-card .card-label {
+  color: #1d4ed8;
+}
+
+.demand-card.active {
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
+}
+
+.video-card {
+  border-color: #86efac;
+  background: linear-gradient(180deg, #f0fdf4 0%, #fff 100%);
+}
+
+.video-card .card-label {
+  color: #15803d;
+  font-variant-numeric: tabular-nums;
+}
+
+.video-card.active {
+  border-color: #16a34a;
+  box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.16);
+}
+
+.arrow-col {
+  width: 40px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding-top: 42px;
+}
+
+.arrow {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+.arrow-line {
+  flex: 1;
+  height: 3px;
+  border-radius: 999px;
+}
+
+.arrow-head {
+  font-size: 11px;
+  line-height: 1;
+  margin-left: -1px;
+}
+
+.arrow-blue .arrow-line {
+  background: #3b82f6;
+}
+.arrow-blue .arrow-head {
+  color: #3b82f6;
+}
+
+.arrow-green .arrow-line {
+  background: #22c55e;
+}
+.arrow-green .arrow-head {
+  color: #22c55e;
+}
+
+.arrow-purple .arrow-line {
+  background: #a855f7;
+}
+.arrow-purple .arrow-head {
+  color: #a855f7;
+}
+
+.topic-card {
+  border: 1.5px solid #d8b4fe;
+  border-radius: 12px;
+  background: linear-gradient(180deg, #faf5ff 0%, #fff 45%);
+  overflow: hidden;
+  max-height: 420px;
+  display: flex;
+  flex-direction: column;
+}
+
+.topic-meta {
+  padding: 8px 12px;
+  border-bottom: 1px solid #f3e8ff;
+  font-size: 11px;
+  font-weight: 600;
+  color: #7e22ce;
+  font-variant-numeric: tabular-nums;
+}
+
+.topic-json {
+  margin: 0;
+  padding: 10px 12px;
+  overflow: auto;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  line-height: 1.45;
+  color: #334155;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.empty {
+  padding: 20px 10px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 12px;
+  border: 1px dashed #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+.empty.hint {
+  border-style: solid;
+  background: transparent;
+}
+
+.empty.error {
+  color: #b91c1c;
+  border-color: #fecaca;
+  background: #fef2f2;
+}
+</style>

+ 1021 - 0
web/src/utils/exportCategoryTreeHtml.ts

@@ -0,0 +1,1021 @@
+import type { CategoryNode, WeightDimMeta } from '../types/category'
+import type { DemandsByCategory } from '../types/demand'
+
+export interface CategoryTreeExportPayload {
+  nodes: CategoryNode[]
+  dims: WeightDimMeta[]
+  bizDt: string | null
+  demandsByCategory: DemandsByCategory
+}
+
+/** Escape text for embedding inside HTML (not attribute). */
+function escHtml(text: string): string {
+  return text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
+/** Safely embed JSON in a <script type="application/json"> block. */
+function embedJson(data: unknown): string {
+  return JSON.stringify(data)
+    .replace(/</g, '\\u003c')
+    .replace(/>/g, '\\u003e')
+    .replace(/&/g, '\\u0026')
+    .replace(/\u2028/g, '\\u2028')
+    .replace(/\u2029/g, '\\u2029')
+}
+
+function buildFilename(bizDt: string | null): string {
+  const stamp = bizDt || new Date().toISOString().slice(0, 10)
+  return `category-tree-${stamp}.html`
+}
+
+/**
+ * Build a standalone HTML snapshot of the category tree page.
+ * Looks and behaves like the live page; all data is embedded (offline).
+ */
+export function buildCategoryTreeHtml(payload: CategoryTreeExportPayload): string {
+  const dataJson = embedJson({
+    nodes: payload.nodes,
+    dims: payload.dims,
+    bizDt: payload.bizDt,
+    demandsByCategory: payload.demandsByCategory,
+  })
+
+  const bizLabel = payload.bizDt
+    ? `biz_dt ${escHtml(payload.bizDt)} · avg`
+    : '暂无权重数据'
+  const bizClass = payload.bizDt ? 'biz-dt' : 'biz-dt warn'
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>全局分类树${payload.bizDt ? ` · ${escHtml(payload.bizDt)}` : ''}(导出)</title>
+  <style>
+${EXPORT_CSS}
+  </style>
+</head>
+<body>
+  <div class="app-shell">
+    <nav class="nav">
+      <span class="brand">SupplyAgent</span>
+      <span class="nav-badge">导出快照</span>
+      <span class="current">全局分类树</span>
+    </nav>
+    <main class="main">
+      <div class="category-tree" id="app" style="--col-width:180px;--col-gap:36px">
+        <header class="toolbar">
+          <div class="title-row">
+            <h1>全局分类树</h1>
+            <span class="${bizClass}">${bizLabel}</span>
+          </div>
+          <div class="controls">
+            <label class="control">
+              <span>展开到第</span>
+              <select id="depth-select"></select>
+              <span>层</span>
+            </label>
+            <button type="button" class="btn" id="expand-all">全部展开</button>
+            <span class="hint" id="hint"></span>
+          </div>
+        </header>
+        <div class="dim-bar">
+          <div class="tabs" id="tabs" role="tablist" aria-label="热度维度"></div>
+          <div class="legend" id="legend" hidden aria-hidden="true">
+            <span class="legend-label">冷</span>
+            <div class="legend-bar"></div>
+            <span class="legend-label">热</span>
+          </div>
+        </div>
+        <div id="empty" class="empty" hidden></div>
+        <div id="tree-panel" class="tree-panel" hidden>
+          <div class="tree-scroll-inner">
+            <div class="level-headers" id="level-headers"></div>
+            <div class="forest" id="forest"></div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+
+  <div id="drawer-overlay" class="overlay" hidden>
+    <aside class="drawer" role="dialog" aria-label="需求词列表">
+      <header class="drawer-header">
+        <div>
+          <h2 id="drawer-title">分类节点</h2>
+          <p class="sub" id="drawer-sub"></p>
+        </div>
+        <button type="button" class="close" id="drawer-close" title="关闭">×</button>
+      </header>
+      <div class="drawer-body" id="drawer-body"></div>
+    </aside>
+  </div>
+
+  <script id="export-data" type="application/json">${dataJson}</script>
+  <script>
+${EXPORT_JS}
+  </script>
+</body>
+</html>`
+}
+
+export function downloadCategoryTreeHtml(payload: CategoryTreeExportPayload): void {
+  const html = buildCategoryTreeHtml(payload)
+  const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = buildFilename(payload.bizDt)
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+  URL.revokeObjectURL(url)
+}
+
+const EXPORT_CSS = `
+:root {
+  font-family: 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+  color: #0f172a;
+  background: #f1f5f9;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+}
+* { box-sizing: border-box; }
+html, body {
+  margin: 0;
+  min-width: 320px;
+  height: 100%;
+  overflow: hidden;
+}
+body {
+  background:
+    radial-gradient(ellipse 80% 50% at 0% 0%, #e0e7ff 0%, transparent 55%),
+    radial-gradient(ellipse 60% 40% at 100% 0%, #dbeafe 0%, transparent 50%),
+    #f1f5f9;
+}
+button, select { font-family: inherit; }
+
+.app-shell {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  overflow: hidden;
+}
+.nav {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 0 28px;
+  height: 48px;
+  flex-shrink: 0;
+  border-bottom: 1px solid #e2e8f0;
+  background: rgba(255, 255, 255, 0.82);
+  backdrop-filter: blur(8px);
+}
+.brand {
+  font-weight: 700;
+  font-size: 15px;
+  color: #0f172a;
+  letter-spacing: -0.02em;
+}
+.nav-badge {
+  display: inline-flex;
+  align-items: center;
+  height: 24px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: #eef2ff;
+  color: #4338ca;
+  font-size: 12px;
+  font-weight: 600;
+}
+.current {
+  margin-left: auto;
+  font-size: 12px;
+  color: #94a3b8;
+}
+.main {
+  flex: 1;
+  min-height: 0;
+  padding: 20px 28px 24px;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.category-tree {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+  max-width: 100%;
+}
+.toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px 24px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #e2e8f0;
+  flex-shrink: 0;
+}
+.title-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 10px 14px;
+}
+.toolbar h1 {
+  margin: 0;
+  font-size: 22px;
+  font-weight: 700;
+  color: #0f172a;
+  letter-spacing: -0.02em;
+}
+.biz-dt {
+  font-size: 12px;
+  color: #64748b;
+  font-variant-numeric: tabular-nums;
+}
+.biz-dt.warn { color: #b45309; }
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px 14px;
+}
+.control {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  color: #334155;
+}
+.control select {
+  height: 32px;
+  padding: 0 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #fff;
+  font-size: 14px;
+  color: #0f172a;
+}
+.btn {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid #334155;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #fff;
+  font-size: 13px;
+  cursor: pointer;
+}
+.btn:hover { background: #1e293b; }
+.hint { font-size: 12px; color: #94a3b8; }
+
+.dim-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px 16px;
+  flex-shrink: 0;
+}
+.tabs {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 4px;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #f8fafc;
+}
+.tab {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  background: transparent;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+}
+.tab:hover { color: #0f172a; background: #fff; }
+.tab.active {
+  background: #0f172a;
+  border-color: #0f172a;
+  color: #fff;
+}
+.legend {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+.legend[hidden] { display: none !important; }
+.legend-label { font-size: 11px; color: #94a3b8; }
+.legend-bar {
+  width: 120px;
+  height: 10px;
+  border-radius: 999px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  background: linear-gradient(
+    90deg,
+    rgb(239, 68, 68) 0%,
+    rgb(255, 255, 255) 50%,
+    rgb(34, 197, 94) 100%
+  );
+}
+
+.tree-panel {
+  flex: 1;
+  min-height: 0;
+  min-width: 0;
+  width: 100%;
+  overflow: scroll;
+  overscroll-behavior: contain;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.55);
+  cursor: grab;
+  scrollbar-gutter: stable;
+}
+.tree-panel[hidden], .empty[hidden] { display: none !important; }
+.tree-panel.is-panning {
+  cursor: grabbing;
+  user-select: none;
+}
+.tree-panel::-webkit-scrollbar { width: 12px; height: 12px; }
+.tree-panel::-webkit-scrollbar-thumb {
+  background: #94a3b8;
+  border-radius: 8px;
+  border: 2px solid transparent;
+  background-clip: content-box;
+}
+.tree-panel::-webkit-scrollbar-track {
+  background: #e2e8f0;
+  border-radius: 8px;
+}
+.tree-scroll-inner {
+  display: inline-block;
+  min-width: 100%;
+  padding: 8px 12px 24px;
+  vertical-align: top;
+}
+.level-headers {
+  display: flex;
+  flex-direction: row;
+  align-items: stretch;
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  background: rgba(248, 250, 252, 0.96);
+  backdrop-filter: blur(6px);
+  border-bottom: 1px solid #e2e8f0;
+  margin-bottom: 12px;
+  padding: 8px 0;
+  width: max-content;
+  min-width: 100%;
+}
+.level-header {
+  width: var(--col-width, 180px);
+  flex-shrink: 0;
+  margin-right: var(--col-gap, 36px);
+  text-align: center;
+  font-size: 13px;
+  font-weight: 600;
+  color: #475569;
+  letter-spacing: 0.02em;
+}
+.level-header:last-child { margin-right: 0; }
+.forest {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 16px;
+  width: max-content;
+  min-width: 100%;
+}
+.empty {
+  padding: 48px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 15px;
+}
+
+.tree-node {
+  --line-y: 20px;
+  --line-color: #cbd5e1;
+  --gap: var(--col-gap, 36px);
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+}
+.node-card {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: var(--col-width, 180px);
+  min-height: 40px;
+  padding: 8px 10px;
+  border: 1px solid #d8dee6;
+  border-radius: 8px;
+  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
+  flex-shrink: 0;
+  box-sizing: border-box;
+}
+.node-card.muted { opacity: 0.72; }
+.toggle {
+  width: 22px;
+  height: 22px;
+  border: 1px solid rgba(15, 23, 42, 0.18);
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.65);
+  color: #334155;
+  font-size: 14px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+  padding: 0;
+}
+.toggle:hover {
+  border-color: #64748b;
+  background: #fff;
+}
+.toggle-spacer { width: 22px; flex-shrink: 0; }
+.node-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: flex-start;
+  gap: 4px;
+}
+.node-text {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.node-name {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.3;
+  word-break: break-word;
+}
+.node-weight {
+  font-size: 11px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  opacity: 0.85;
+  letter-spacing: 0.01em;
+}
+.inspect {
+  flex-shrink: 0;
+  width: 24px;
+  height: 24px;
+  margin-top: -1px;
+  border: none;
+  border-radius: 4px;
+  background: transparent;
+  font-size: 13px;
+  line-height: 1;
+  cursor: pointer;
+  padding: 0;
+}
+.inspect:hover { background: rgba(15, 23, 42, 0.08); }
+.children {
+  --half-gap: calc(var(--gap) / 2);
+  --sibling-gap: 10px;
+  display: flex;
+  flex-direction: column;
+  list-style: none;
+  margin: 0;
+  padding: 0 0 0 var(--gap);
+  position: relative;
+}
+.children::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: var(--line-y);
+  width: var(--half-gap);
+  border-top: 2px solid var(--line-color);
+}
+.child { position: relative; }
+.child:not(:last-child) { padding-bottom: var(--sibling-gap); }
+.child::before {
+  content: '';
+  position: absolute;
+  top: var(--line-y);
+  left: 0;
+  width: var(--half-gap);
+  margin-left: calc(-1 * var(--half-gap));
+  border-top: 2px solid var(--line-color);
+}
+.child::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  margin-left: calc(-1 * var(--half-gap));
+  border-left: 2px solid var(--line-color);
+}
+.child:not(:first-child):not(:last-child)::after { top: 0; bottom: 0; }
+.child:first-child:not(:last-child)::after { top: var(--line-y); bottom: 0; }
+.child:last-child:not(:first-child)::after { top: 0; height: var(--line-y); }
+.child:only-child::after { display: none; }
+
+.overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  background: rgba(15, 23, 42, 0.28);
+  display: flex;
+  justify-content: flex-end;
+}
+.overlay[hidden] { display: none !important; }
+.drawer {
+  width: min(480px, 100vw);
+  height: 100%;
+  background: #fff;
+  box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
+  display: flex;
+  flex-direction: column;
+}
+.drawer-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 20px 20px 16px;
+  border-bottom: 1px solid #e2e8f0;
+}
+.drawer-header h2 {
+  margin: 0;
+  font-size: 18px;
+  color: #0f172a;
+  word-break: break-word;
+}
+.sub {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: #94a3b8;
+}
+.close {
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #64748b;
+  font-size: 22px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.close:hover { background: #f1f5f9; color: #0f172a; }
+.drawer-body {
+  flex: 1;
+  overflow: auto;
+  padding: 0 0 24px;
+}
+.drawer-body table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+.drawer-body th,
+.drawer-body td {
+  padding: 10px 16px;
+  text-align: left;
+  vertical-align: top;
+  border-bottom: 1px solid #eef2f7;
+  color: #0f172a;
+  word-break: break-word;
+}
+.drawer-body th {
+  position: sticky;
+  top: 0;
+  background: #f8fafc;
+  font-size: 12px;
+  font-weight: 600;
+  color: #64748b;
+  letter-spacing: 0.02em;
+}
+.drawer-body .col-name { width: 36%; }
+.drawer-body .col-reason { width: 64%; }
+.drawer-body tbody tr:hover td { background: #f8fafc; }
+.drawer-empty {
+  padding: 48px 20px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 14px;
+}
+`
+
+const EXPORT_JS = `
+(function () {
+  const FULL_TREE_KEY = 'full';
+  const DEFAULT_DEPTH = 3;
+  const DEFAULT_DIMS = [
+    { key: 'ext_pop', label: '外部热度' },
+    { key: 'plat_sust_pop', label: '平台持续热度' },
+    { key: 'plat_ly_pop', label: '去年同期热度' },
+    { key: 'recent_pop', label: '近期热度' },
+    { key: 'real_rov_7d', label: '真实ROV(7日)' },
+    { key: 'real_vov_7d', label: '真实VOV(7日)' },
+  ];
+
+  const raw = JSON.parse(document.getElementById('export-data').textContent);
+  const allNodes = raw.nodes || [];
+  const dims = (raw.dims && raw.dims.length) ? raw.dims : DEFAULT_DIMS;
+  const demandsByCategory = raw.demandsByCategory || {};
+
+  const state = {
+    activeTab: FULL_TREE_KEY,
+    expandDepth: DEFAULT_DEPTH,
+    expandedMap: {},
+    collapsedMap: {},
+  };
+
+  const els = {
+    tabs: document.getElementById('tabs'),
+    legend: document.getElementById('legend'),
+    depthSelect: document.getElementById('depth-select'),
+    expandAll: document.getElementById('expand-all'),
+    hint: document.getElementById('hint'),
+    empty: document.getElementById('empty'),
+    treePanel: document.getElementById('tree-panel'),
+    levelHeaders: document.getElementById('level-headers'),
+    forest: document.getElementById('forest'),
+    overlay: document.getElementById('drawer-overlay'),
+    drawerTitle: document.getElementById('drawer-title'),
+    drawerSub: document.getElementById('drawer-sub'),
+    drawerBody: document.getElementById('drawer-body'),
+    drawerClose: document.getElementById('drawer-close'),
+  };
+
+  function esc(text) {
+    return String(text == null ? '' : text)
+      .replace(/&/g, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;');
+  }
+
+  function maxTreeDepth(nodes, depth) {
+    depth = depth || 1;
+    if (!nodes.length) return 0;
+    let max = depth;
+    for (const node of nodes) {
+      if (node.children && node.children.length) {
+        max = Math.max(max, maxTreeDepth(node.children, depth + 1));
+      }
+    }
+    return max;
+  }
+
+  function filterTreeByDim(nodes, dim) {
+    const out = [];
+    for (const node of nodes) {
+      const children = filterTreeByDim(node.children || [], dim);
+      const hasData = ((node.counts && node.counts[dim]) || 0) > 0;
+      if (hasData || children.length) {
+        out.push(Object.assign({}, node, { children: children }));
+      }
+    }
+    return out;
+  }
+
+  function collectWeights(nodes, dim, out) {
+    out = out || [];
+    for (const node of nodes) {
+      const w = node.weights && node.weights[dim];
+      const c = (node.counts && node.counts[dim]) || 0;
+      if (typeof w === 'number' && c > 0) out.push(w);
+      if (node.children && node.children.length) collectWeights(node.children, dim, out);
+    }
+    return out;
+  }
+
+  function buildWeightScale(values) {
+    if (!values.length) return [];
+    return values.slice().sort(function (a, b) { return a - b; });
+  }
+
+  function weightHeatT(weight, sortedScale) {
+    if (!sortedScale.length) return null;
+    const n = sortedScale.length;
+    let lo = 0, hi = n;
+    while (lo < hi) {
+      const mid = (lo + hi) >> 1;
+      if (sortedScale[mid] < weight) lo = mid + 1;
+      else hi = mid;
+    }
+    if (n === 1) return 1;
+    return Math.min(1, Math.max(0, lo / n));
+  }
+
+  function heatColor(t) {
+    if (t == null) return '#f1f5f9';
+    const clamp = Math.min(1, Math.max(0, t));
+    if (clamp <= 0.5) {
+      const u = clamp / 0.5;
+      const r = Math.round(239 + (255 - 239) * u);
+      const g = Math.round(68 + (255 - 68) * u);
+      const b = Math.round(68 + (255 - 68) * u);
+      return 'rgb(' + r + ', ' + g + ', ' + b + ')';
+    }
+    const u = (clamp - 0.5) / 0.5;
+    const r = Math.round(255 + (34 - 255) * u);
+    const g = Math.round(255 + (197 - 255) * u);
+    const b = Math.round(255 + (94 - 255) * u);
+    return 'rgb(' + r + ', ' + g + ', ' + b + ')';
+  }
+
+  function heatTextColor(t) {
+    if (t == null) return '#64748b';
+    return '#0f172a';
+  }
+
+  function formatAvgScore(avg, count) {
+    if (count != null && count <= 0) return '—';
+    if (avg == null || Number.isNaN(avg)) return '—';
+    if (avg >= 100) return avg.toFixed(0);
+    if (avg >= 1) return avg.toFixed(1);
+    return avg.toFixed(2);
+  }
+
+  function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
+  function activeDim() { return isFullTree() ? null : state.activeTab; }
+
+  function displayNodes() {
+    const dim = activeDim();
+    if (!dim) return allNodes;
+    return filterTreeByDim(allNodes, dim);
+  }
+
+  function weightScale() {
+    const dim = activeDim();
+    if (!dim) return [];
+    return buildWeightScale(collectWeights(displayNodes(), dim));
+  }
+
+  function resetExpandState() {
+    state.expandedMap = {};
+    state.collapsedMap = {};
+  }
+
+  function findDepth(nodes, id, depth) {
+    for (const node of nodes) {
+      if (node.id === id) return depth;
+      const found = findDepth(node.children || [], id, depth + 1);
+      if (found != null) return found;
+    }
+    return null;
+  }
+
+  function isExpanded(node, depth) {
+    if (!node.children || !node.children.length) return false;
+    if (state.collapsedMap[node.id]) return false;
+    if (state.expandedMap[node.id]) return true;
+    return depth < state.expandDepth;
+  }
+
+  function toggleNode(id) {
+    const wasCollapsed = !!state.collapsedMap[id];
+    const wasExpanded = !!state.expandedMap[id];
+    const nextExpanded = Object.assign({}, state.expandedMap);
+    const nextCollapsed = Object.assign({}, state.collapsedMap);
+    delete nextExpanded[id];
+    delete nextCollapsed[id];
+
+    if (wasCollapsed) {
+      nextExpanded[id] = true;
+    } else if (wasExpanded) {
+      nextCollapsed[id] = true;
+    } else {
+      const depth = findDepth(displayNodes(), id, 1);
+      const autoOpen = depth != null && depth < state.expandDepth;
+      if (autoOpen) nextCollapsed[id] = true;
+      else nextExpanded[id] = true;
+    }
+    state.expandedMap = nextExpanded;
+    state.collapsedMap = nextCollapsed;
+    renderTree();
+  }
+
+  function renderNode(node, depth, scale, dim) {
+    const hasChildren = !!(node.children && node.children.length);
+    const expanded = isExpanded(node, depth);
+    const demands = demandsByCategory[node.id] || [];
+    const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
+    const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
+    let heatT = null;
+    if (dim && count > 0 && weight != null) heatT = weightHeatT(weight, scale);
+    const bg = heatColor(heatT);
+    const color = heatTextColor(heatT);
+    const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
+    const muted = dim != null && heatT == null ? ' muted' : '';
+    const openCls = expanded ? ' open' : '';
+    const leafCls = hasChildren ? '' : ' leaf';
+    const name = node.name || '(未命名)';
+    let title;
+    if (!dim) title = name;
+    else if (count > 0) title = 'avg=' + weight + ' count=' + count;
+    else title = '无数据';
+
+    let html = '<div class="tree-node">';
+    html += '<div class="node-card' + leafCls + openCls + muted + '" style="background:' + bg +
+      ';color:' + color + ';border-color:' + borderColor + '" title="' + esc(title) + '">';
+    if (hasChildren) {
+      html += '<button class="toggle" type="button" data-toggle="' + node.id +
+        '" aria-expanded="' + expanded + '" title="' + (expanded ? '收起' : '展开') + '">' +
+        (expanded ? '−' : '+') + '</button>';
+    } else {
+      html += '<span class="toggle-spacer"></span>';
+    }
+    html += '<div class="node-main"><div class="node-text">';
+    html += '<span class="node-name">' + esc(name) + '</span>';
+    if (dim) {
+      html += '<span class="node-weight">' + esc(formatAvgScore(weight, count)) + '</span>';
+    }
+    html += '</div>';
+    if (demands.length) {
+      html += '<button class="inspect" type="button" data-inspect="' + node.id +
+        '" data-name="' + esc(name) + '" title="查看需求词">🔍</button>';
+    }
+    html += '</div></div>';
+
+    if (hasChildren && expanded) {
+      html += '<ul class="children">';
+      for (const child of node.children) {
+        html += '<li class="child">' + renderNode(child, depth + 1, scale, dim) + '</li>';
+      }
+      html += '</ul>';
+    }
+    html += '</div>';
+    return html;
+  }
+
+  function headerLevels(treeMax) {
+    let count = Math.max(state.expandDepth, 1);
+    if (Object.keys(state.expandedMap).length > 0) {
+      count = Math.max(count, treeMax);
+    }
+    if (treeMax > 0) count = Math.min(count, treeMax);
+    const levels = [];
+    for (let i = 1; i <= count; i++) levels.push(i);
+    return levels;
+  }
+
+  function renderTabs() {
+    const tabs = [{ key: FULL_TREE_KEY, label: '完整全局树' }].concat(
+      dims.map(function (d) { return { key: d.key, label: d.label }; })
+    );
+    els.tabs.innerHTML = tabs.map(function (t) {
+      const active = state.activeTab === t.key ? ' active' : '';
+      return '<button type="button" role="tab" class="tab' + active +
+        '" data-tab="' + esc(t.key) + '" aria-selected="' + (state.activeTab === t.key) + '">' +
+        esc(t.label) + '</button>';
+    }).join('');
+  }
+
+  function renderDepthOptions(treeMax) {
+    const max = Math.max(treeMax, DEFAULT_DEPTH);
+    let html = '';
+    for (let n = 1; n <= max; n++) {
+      html += '<option value="' + n + '"' + (n === state.expandDepth ? ' selected' : '') + '>' + n + '</option>';
+    }
+    els.depthSelect.innerHTML = html;
+  }
+
+  function renderTree() {
+    const nodes = displayNodes();
+    const dim = activeDim();
+    const scale = weightScale();
+    const treeMax = maxTreeDepth(nodes);
+
+    renderTabs();
+    renderDepthOptions(treeMax);
+    els.legend.hidden = isFullTree();
+    els.hint.textContent = '共 ' + treeMax + ' 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚';
+
+    if (!nodes.length) {
+      els.empty.hidden = false;
+      els.treePanel.hidden = true;
+      els.empty.textContent = isFullTree() ? '暂无分类数据' : '该维度暂无有数据的节点';
+      return;
+    }
+
+    els.empty.hidden = true;
+    els.treePanel.hidden = false;
+    els.levelHeaders.innerHTML = headerLevels(treeMax).map(function (n) {
+      return '<div class="level-header">第 ' + n + ' 层</div>';
+    }).join('');
+    els.forest.innerHTML = nodes.map(function (node) {
+      return renderNode(node, 1, scale, dim);
+    }).join('');
+  }
+
+  function openDrawer(categoryId, categoryName) {
+    const items = demandsByCategory[categoryId] || [];
+    els.drawerTitle.textContent = categoryName || '分类节点';
+    els.drawerSub.textContent = '共 ' + items.length + ' 条需求词';
+    if (!items.length) {
+      els.drawerBody.innerHTML = '<div class="drawer-empty">该节点暂无需求词</div>';
+    } else {
+      els.drawerBody.innerHTML =
+        '<table><thead><tr><th class="col-name">需求词</th><th class="col-reason">原因</th></tr></thead><tbody>' +
+        items.map(function (item) {
+          return '<tr><td>' + esc(item.name || '—') + '</td><td>' + esc(item.reason || '—') + '</td></tr>';
+        }).join('') +
+        '</tbody></table>';
+    }
+    els.overlay.hidden = false;
+  }
+
+  function closeDrawer() {
+    els.overlay.hidden = true;
+  }
+
+  els.tabs.addEventListener('click', function (e) {
+    const btn = e.target.closest('[data-tab]');
+    if (!btn) return;
+    state.activeTab = btn.getAttribute('data-tab');
+    state.expandDepth = DEFAULT_DEPTH;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.depthSelect.addEventListener('change', function () {
+    state.expandDepth = Number(els.depthSelect.value) || DEFAULT_DEPTH;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.expandAll.addEventListener('click', function () {
+    state.expandDepth = maxTreeDepth(displayNodes()) || 1;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.forest.addEventListener('click', function (e) {
+    const toggle = e.target.closest('[data-toggle]');
+    if (toggle) {
+      e.stopPropagation();
+      toggleNode(Number(toggle.getAttribute('data-toggle')));
+      return;
+    }
+    const inspect = e.target.closest('[data-inspect]');
+    if (inspect) {
+      e.stopPropagation();
+      openDrawer(
+        Number(inspect.getAttribute('data-inspect')),
+        inspect.getAttribute('data-name') || '(未命名)'
+      );
+    }
+  });
+
+  els.drawerClose.addEventListener('click', closeDrawer);
+  els.overlay.addEventListener('click', function (e) {
+    if (e.target === els.overlay) closeDrawer();
+  });
+
+  // Pan
+  let isPanning = false, panStartX = 0, panStartY = 0, panScrollLeft = 0, panScrollTop = 0;
+  function onPanMove(e) {
+    if (!isPanning) return;
+    els.treePanel.scrollLeft = panScrollLeft - (e.clientX - panStartX);
+    els.treePanel.scrollTop = panScrollTop - (e.clientY - panStartY);
+  }
+  function onPanEnd() {
+    isPanning = false;
+    els.treePanel.classList.remove('is-panning');
+    window.removeEventListener('mousemove', onPanMove);
+    window.removeEventListener('mouseup', onPanEnd);
+  }
+  els.treePanel.addEventListener('mousedown', function (e) {
+    if (e.button !== 0) return;
+    if (e.target.closest('button, select, a, input')) return;
+    isPanning = true;
+    panStartX = e.clientX;
+    panStartY = e.clientY;
+    panScrollLeft = els.treePanel.scrollLeft;
+    panScrollTop = els.treePanel.scrollTop;
+    els.treePanel.classList.add('is-panning');
+    window.addEventListener('mousemove', onPanMove);
+    window.addEventListener('mouseup', onPanEnd);
+  });
+
+  renderTree();
+})();
+`