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

增加新热事件来源展示

xueyiming 1 день назад
Родитель
Сommit
6137a40a68

+ 9 - 0
.env.example

@@ -9,6 +9,15 @@ MYSQL_PORT=3306
 MYSQL_USER=demand
 MYSQL_PASSWORD=
 MYSQL_DATABASE=demand-server
+# 新热事件来源库(hot_content_odps_sync_log / hot_content_records 等)
+HOT_CONTENT_MYSQL_HOST=
+HOT_CONTENT_MYSQL_PORT=3306
+HOT_CONTENT_MYSQL_USER=
+HOT_CONTENT_MYSQL_PASSWORD=
+HOT_CONTENT_MYSQL_DATABASE=external_demand
+HOT_CONTENT_MYSQL_CHARSET=utf8mb4
+HOT_DEMAND_POOL_STRATEGY=新热事件
+HOT_CONTENT_WXINDEX_THRESHOLD=1000000
 ODPS_ACCESS_ID=
 ODPS_ACCESS_KEY=
 ODPS_PROJECT=

+ 1 - 0
.gitignore

@@ -43,3 +43,4 @@ pnpm-debug.log*
 dist/
 build/
 coverage/
+/crawler/

+ 27 - 0
app/api/routes.py

@@ -12,6 +12,7 @@ from app.services.demand_pool_service import (
     query_demand_pool_records,
     query_strategy_options,
 )
+from app.services.hot_content_source_service import fetch_hot_content_source_detail
 from app.services.element_search_service import (
     query_monthly_element_demands,
     query_same_period_last_year_element_demands,
@@ -372,6 +373,32 @@ async def get_video_decode_page_url(
     return {"url2": url2}
 
 
+@router.get("/demand-pool/hot-content-source")
+async def get_demand_pool_hot_content_source(
+    demand_name: str = Query(..., min_length=1, description="需求名称"),
+    demand_type: str = Query(..., min_length=1, description="需求类型,如 短语 / 特征点"),
+    dt: str = Query(
+        ...,
+        min_length=1,
+        description="分区日期,yyyymmdd 或 yyyy-mm-dd",
+    ),
+    strategy: str | None = Query(default=None, description="策略名,默认新热事件"),
+) -> dict[str, object]:
+    try:
+        return fetch_hot_content_source_detail(
+            demand_name=demand_name,
+            demand_type=demand_type,
+            partition_dt=dt,
+            strategy=strategy,
+        )
+    except RuntimeError as exc:
+        raise HTTPException(status_code=503, detail=str(exc)) from exc
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    except LookupError as exc:
+        raise HTTPException(status_code=404, 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"),

+ 24 - 0
app/core/config.py

@@ -15,6 +15,13 @@ class Settings(BaseSettings):
     mysql_user: str = "root"
     mysql_password: str = ""
     mysql_database: str = ""
+    # 新热事件来源库(hot_content_* 表),与需求池库分离
+    hot_content_mysql_host: str = ""
+    hot_content_mysql_port: int = 3306
+    hot_content_mysql_user: str = ""
+    hot_content_mysql_password: str = ""
+    hot_content_mysql_database: str = ""
+    hot_content_mysql_charset: str = "utf8mb4"
     odps_access_id: str = "LTAI9EBa0bd5PrDa"
     odps_access_key: str = "vAalxds7YxhfOA2yVv8GziCg3Y87v5"
     odps_project: str = "loghubods"
@@ -36,6 +43,8 @@ class Settings(BaseSettings):
     feishu_webhook_timeout_seconds: int = 30
     # 默认不校验 HTTPS 证书,避免公司代理自签链导致发不出消息;生产若需严格校验可设为 true
     feishu_webhook_verify_ssl: bool = False
+    hot_demand_pool_strategy: str = "新热事件"
+    hot_content_wxindex_threshold: float = 1_000_000.0
 
     model_config = SettingsConfigDict(
         env_file=".env",
@@ -51,6 +60,21 @@ class Settings(BaseSettings):
             f"@{self.mysql_host}:{self.mysql_port}/{quote_plus(self.mysql_database)}"
         )
 
+    @property
+    def hot_content_mysql_configured(self) -> bool:
+        return bool(self.hot_content_mysql_host.strip() and self.hot_content_mysql_database.strip())
+
+    @property
+    def hot_content_mysql_dsn(self) -> str:
+        charset = quote_plus(self.hot_content_mysql_charset or "utf8mb4")
+        return (
+            "mysql+pymysql://"
+            f"{quote_plus(self.hot_content_mysql_user)}:"
+            f"{quote_plus(self.hot_content_mysql_password)}"
+            f"@{self.hot_content_mysql_host}:{self.hot_content_mysql_port}/"
+            f"{quote_plus(self.hot_content_mysql_database)}?charset={charset}"
+        )
+
     @property
     def demand_pool_initial_partition_list(self) -> list[str]:
         return [

+ 52 - 0
app/db/hot_content_mysql.py

@@ -0,0 +1,52 @@
+from collections.abc import Generator
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.core.config import settings
+
+_hot_content_engine: Engine | None = None
+_hot_content_session_local: sessionmaker[Session] | None = None
+
+
+def _ensure_hot_content_engine() -> sessionmaker[Session]:
+    global _hot_content_engine, _hot_content_session_local
+    if not settings.hot_content_mysql_configured:
+        raise RuntimeError(
+            "新热内容库未配置,请在 .env 中设置 HOT_CONTENT_MYSQL_HOST、"
+            "HOT_CONTENT_MYSQL_USER、HOT_CONTENT_MYSQL_PASSWORD、HOT_CONTENT_MYSQL_DATABASE"
+        )
+    if _hot_content_session_local is None:
+        _hot_content_engine = create_engine(
+            settings.hot_content_mysql_dsn,
+            pool_pre_ping=True,
+            pool_recycle=3600,
+        )
+        _hot_content_session_local = sessionmaker(
+            bind=_hot_content_engine,
+            autoflush=False,
+            autocommit=False,
+        )
+    return _hot_content_session_local
+
+
+def HotContentSessionLocal() -> Session:
+    """打开新热内容库会话(独立 MySQL,用法同 SessionLocal())。"""
+    return _ensure_hot_content_engine()()
+
+
+def get_hot_content_db_session() -> Generator[Session, None, None]:
+    db = HotContentSessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()
+
+
+def hot_content_mysql_ping() -> bool:
+    _ensure_hot_content_engine()
+    assert _hot_content_engine is not None
+    with _hot_content_engine.connect() as conn:
+        conn.execute(text("SELECT 1"))
+    return True

+ 347 - 0
app/services/hot_content_source_service.py

@@ -0,0 +1,347 @@
+"""新热事件需求词来源:按 sync_log 定位热点标题并返回单条展示数据。"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.config import settings
+from app.db.hot_content_mysql import HotContentSessionLocal
+
+TYPE_FEATURE_POINT = "特征点"
+TYPE_PHRASE = "短语"
+ITEM_TYPE_ELEMENT = "元素"
+ITEM_TYPE_PHRASE = "短语"
+TITLE_RETAIN_POINT_CATEGORIES = frozenset({"灵感点", "目的点"})
+WEIGHT_DIVISOR = 1_000_000.0
+DEFAULT_WXINDEX_THRESHOLD = 1_000_000.0
+DEFAULT_HOT_STRATEGY = "新热事件"
+
+
+def _hot_strategy_name() -> str:
+    return str(
+        getattr(settings, "hot_demand_pool_strategy", None) or DEFAULT_HOT_STRATEGY
+    ).strip() or DEFAULT_HOT_STRATEGY
+
+
+def _wxindex_threshold() -> float:
+    raw = getattr(settings, "hot_content_wxindex_threshold", None)
+    if raw is None:
+        return DEFAULT_WXINDEX_THRESHOLD
+    try:
+        return float(raw)
+    except (TypeError, ValueError):
+        return DEFAULT_WXINDEX_THRESHOLD
+
+
+def _normalize_date(date_value: str | None) -> str:
+    if not date_value:
+        return ""
+    normalized = str(date_value).replace("-", "").strip()
+    return normalized if len(normalized) == 8 and normalized.isdigit() else ""
+
+
+def _normalize_text(value: str) -> str:
+    return "".join(str(value or "").split())
+
+
+def _load_json(value: Any) -> dict[str, Any]:
+    if value is None:
+        return {}
+    if isinstance(value, dict):
+        return value
+    if isinstance(value, (bytes, bytearray)):
+        value = value.decode("utf-8")
+    if isinstance(value, str):
+        text_value = value.strip()
+        if not text_value:
+            return {}
+        try:
+            parsed = json.loads(text_value)
+        except json.JSONDecodeError:
+            return {"__parse_error__": text_value}
+        return parsed if isinstance(parsed, dict) else {"value": parsed}
+    return {"value": value}
+
+
+def _fmt_datetime(value: Any) -> str:
+    if value is None:
+        return "-"
+    if isinstance(value, datetime):
+        return value.strftime("%Y-%m-%d %H:%M:%S")
+    return str(value)
+
+
+def build_demand_id(*, strategy: str, demand_name: str, partition_dt: str) -> str:
+    raw = f"{strategy}{demand_name.strip()}{partition_dt}"
+    return hashlib.md5(raw.encode("utf-8")).hexdigest()
+
+
+def _record_wxindex_score(export_rows: list[dict[str, Any]]) -> float:
+    scores: list[float] = []
+    for row in export_rows:
+        try:
+            scores.append(float(row.get("wxindex_latest_score") or 0))
+        except (TypeError, ValueError):
+            continue
+    return max(scores) if scores else 0.0
+
+
+def _has_inspiration_or_goal_demand_match(row: dict[str, Any]) -> bool:
+    point_category = str(row.get("point_category") or "").strip()
+    if point_category not in TITLE_RETAIN_POINT_CATEGORIES:
+        return False
+    return bool(str(row.get("matched_demand") or "").strip())
+
+
+def _export_row_contributes_to_demand(
+    row: dict[str, Any],
+    *,
+    demand_name: str,
+    demand_type: str,
+) -> bool:
+    normalized_name = _normalize_text(demand_name)
+    item_type = str(row.get("item_type") or "")
+    item_text = str(row.get("item_text") or "")
+    matched = str(row.get("matched_demand") or "").strip()
+    if not matched:
+        return False
+    if demand_type == TYPE_PHRASE:
+        return item_type == ITEM_TYPE_PHRASE and _normalize_text(item_text) == normalized_name
+    if demand_type == TYPE_FEATURE_POINT:
+        return item_type == ITEM_TYPE_ELEMENT and _normalize_text(item_text) in normalized_name
+    return False
+
+
+def _hive_weight_for_record(
+    export_rows: list[dict[str, Any]],
+    *,
+    wxindex_threshold: float,
+) -> float | None:
+    if _record_wxindex_score(export_rows) < wxindex_threshold:
+        return None
+    if not any(_has_inspiration_or_goal_demand_match(row) for row in export_rows):
+        return None
+    return _record_wxindex_score(export_rows) / WEIGHT_DIVISOR
+
+
+def fetch_sync_log_row(
+    *,
+    demand_name: str,
+    demand_type: str,
+    partition_dt: str,
+    strategy: str | None = None,
+) -> dict[str, Any] | None:
+    """按需求名称、需求类型、分区 dt 查询 hot_content_odps_sync_log 唯一记录。"""
+    name = (demand_name or "").strip()
+    dtype = (demand_type or "").strip()
+    dt = _normalize_date(partition_dt)
+    if not name or not dtype or not dt:
+        raise ValueError("demand_name、demand_type、dt 均为必填")
+    strategy_value = (strategy or _hot_strategy_name()).strip()
+    sql = text(
+        """
+        SELECT
+            id,
+            partition_dt,
+            strategy,
+            demand_id,
+            demand_name,
+            demand_type,
+            record_id,
+            synced_at
+        FROM hot_content_odps_sync_log
+        WHERE demand_name = :demand_name
+          AND demand_type = :demand_type
+          AND partition_dt = :partition_dt
+          AND strategy = :strategy
+        LIMIT 2
+        """
+    )
+    params = {
+        "demand_name": name,
+        "demand_type": dtype,
+        "partition_dt": dt,
+        "strategy": strategy_value,
+    }
+    with HotContentSessionLocal() as session:
+        rows = session.execute(sql, params).mappings().all()
+    if not rows:
+        return None
+    if len(rows) > 1:
+        raise ValueError("匹配到多条同步记录,请检查查询条件")
+    return dict(rows[0])
+
+
+def fetch_hot_content_source_detail(
+    *,
+    demand_name: str,
+    demand_type: str,
+    partition_dt: str,
+    strategy: str | None = None,
+) -> dict[str, Any]:
+    sync_row = fetch_sync_log_row(
+        demand_name=demand_name,
+        demand_type=demand_type,
+        partition_dt=partition_dt,
+        strategy=strategy,
+    )
+    if sync_row is None:
+        raise LookupError("未找到对应的同步记录")
+
+    record_id = int(sync_row.get("record_id") or 0)
+    if record_id <= 0:
+        raise LookupError("同步记录未关联热点内容")
+
+    wxindex_threshold = _wxindex_threshold()
+    strategy_value = str(sync_row.get("strategy") or _hot_strategy_name())
+    partition_dt = str(sync_row.get("partition_dt") or "")
+    sync_demand_name = str(sync_row.get("demand_name") or "")
+    sync_demand_type = str(sync_row.get("demand_type") or "")
+    demand_id = str(sync_row.get("demand_id") or "")
+
+    record_sql = text(
+        """
+        SELECT
+            id,
+            source,
+            title,
+            article_title,
+            article_body,
+            hot_rank,
+            execution_status,
+            postprocess_status,
+            created_at,
+            contribution_demand_match_json,
+            wxindex_trend_json
+        FROM hot_content_records
+        WHERE id = :record_id
+        LIMIT 1
+        """
+    )
+    export_sql = text(
+        """
+        SELECT
+            id,
+            item_type,
+            item_text,
+            point_category,
+            matched_demand,
+            contribution_score,
+            wxindex_keyword,
+            all_hot_keywords,
+            wxindex_latest_score,
+            wxindex_trend
+        FROM hot_content_demand_exports
+        WHERE record_id = :record_id
+        ORDER BY id ASC
+        """
+    )
+    with HotContentSessionLocal() as session:
+        record_row = session.execute(record_sql, {"record_id": record_id}).mappings().first()
+        export_rows_raw = session.execute(export_sql, {"record_id": record_id}).mappings().all()
+
+    if record_row is None:
+        raise LookupError("未找到关联热点内容")
+
+    export_dicts: list[dict[str, Any]] = []
+    export_items: list[dict[str, Any]] = []
+    for row in export_rows_raw:
+        item = dict(row)
+        contributes = _export_row_contributes_to_demand(
+            item,
+            demand_name=sync_demand_name,
+            demand_type=sync_demand_type,
+        )
+        export_dicts.append(
+            {
+                "item_type": item.get("item_type"),
+                "item_text": item.get("item_text"),
+                "point_category": item.get("point_category"),
+                "matched_demand": item.get("matched_demand"),
+                "wxindex_latest_score": item.get("wxindex_latest_score"),
+            }
+        )
+        export_items.append(
+            {
+                "id": int(item["id"]),
+                "item_type": str(item.get("item_type") or ""),
+                "item_text": str(item.get("item_text") or ""),
+                "point_category": str(item.get("point_category") or ""),
+                "matched_demand": str(item.get("matched_demand") or ""),
+                "contribution_score": float(item["contribution_score"])
+                if item.get("contribution_score") is not None
+                else None,
+                "wxindex_keyword": str(item.get("wxindex_keyword") or ""),
+                "all_hot_keywords": str(item.get("all_hot_keywords") or ""),
+                "wxindex_latest_score": float(item.get("wxindex_latest_score") or 0),
+                "wxindex_trend": str(item.get("wxindex_trend") or ""),
+                "contributes_to_sync": contributes,
+            }
+        )
+
+    max_wxindex = _record_wxindex_score(export_dicts)
+    hive_weight = _hive_weight_for_record(
+        export_dicts,
+        wxindex_threshold=wxindex_threshold,
+    )
+    expected_id = build_demand_id(
+        strategy=strategy_value,
+        demand_name=sync_demand_name,
+        partition_dt=partition_dt,
+    )
+
+    contribution = _load_json(record_row.get("contribution_demand_match_json"))
+    wxindex = _load_json(record_row.get("wxindex_trend_json"))
+
+    return {
+        "wxindex_threshold": wxindex_threshold,
+        "sync_log": {
+            "id": int(sync_row["id"]),
+            "partition_dt": partition_dt,
+            "strategy": strategy_value,
+            "demand_id": demand_id,
+            "demand_name": sync_demand_name,
+            "demand_type": sync_demand_type,
+            "record_id": record_id,
+            "synced_at": _fmt_datetime(sync_row.get("synced_at")),
+            "demand_id_verified": expected_id == demand_id,
+            "hive_weight": hive_weight,
+        },
+        "record": {
+            "id": int(record_row["id"]),
+            "source": str(record_row.get("source") or ""),
+            "title": str(record_row.get("title") or ""),
+            "article_title": str(record_row.get("article_title") or ""),
+            "article_body": str(record_row.get("article_body") or ""),
+            "hot_rank": int(record_row["hot_rank"])
+            if record_row.get("hot_rank") is not None
+            else None,
+            "created_at": _fmt_datetime(record_row.get("created_at")),
+            "contribution": contribution,
+            "wxindex": wxindex,
+            "max_wxindex_score": max_wxindex,
+            "passes_wxindex_gate": max_wxindex >= wxindex_threshold,
+            "passes_point_gate": any(
+                _has_inspiration_or_goal_demand_match(row) for row in export_dicts
+            ),
+        },
+        "export_rows": export_items,
+    }
+
+
+def format_number(value: Any) -> str:
+    try:
+        number = float(value)
+    except (TypeError, ValueError):
+        return "-"
+    if math.isnan(number):
+        return "-"
+    if abs(number) >= 10000:
+        return f"{number / 10000:.1f}万"
+    return f"{number:.0f}"

+ 103 - 5
frontend/src/App.tsx

@@ -22,6 +22,7 @@ import type { ColumnsType } from "antd/es/table";
 import type { SortOrder } from "antd/es/table/interface";
 import dayjs from "dayjs";
 import type { Dayjs } from "dayjs";
+import HotContentSourcePage from "./HotContentSourcePage";
 
 type DemandPoolItem = {
   id: number;
@@ -147,7 +148,39 @@ async function downloadExcelExport(url: string, defaultFilename: string) {
 const CMS_VIDEO_POST_DETAIL_BASE =
   "https://admin.piaoquantv.com/cms/post-detail/";
 
-function DemandPoolPanel() {
+const HOT_DEMAND_POOL_STRATEGY = "新热事件";
+
+type HotSourceViewParams = {
+  demandName: string;
+  demandType: string;
+  dt: string;
+};
+
+function readHotSourceViewFromUrl(): HotSourceViewParams | null {
+  const params = new URLSearchParams(window.location.search);
+  if (params.get("view") !== "hot-source") {
+    return null;
+  }
+  const demandName = (params.get("demand_name") ?? "").trim();
+  const demandType = (params.get("demand_type") ?? "").trim();
+  const dt = (params.get("dt") ?? "").trim();
+  if (!demandName || !demandType || !dt) {
+    return null;
+  }
+  return { demandName, demandType, dt };
+}
+
+function buildHotSourceViewUrl(params: HotSourceViewParams): string {
+  const search = new URLSearchParams({
+    view: "hot-source",
+    demand_name: params.demandName,
+    demand_type: params.demandType,
+    dt: params.dt,
+  });
+  return `${window.location.pathname}?${search.toString()}`;
+}
+
+function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSourceViewParams) => void }) {
   const [strategies, setStrategies] = useState<string[]>([]);
   const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
     const today = dayjs();
@@ -450,8 +483,40 @@ function DemandPoolPanel() {
         sorter: true,
         sortOrder: getSortOrderForColumn("dt"),
       },
+      {
+        title: "操作",
+        key: "actions",
+        width: 110,
+        fixed: "right",
+        render: (_, record) => {
+          if ((record.strategy ?? "").trim() !== HOT_DEMAND_POOL_STRATEGY) {
+            return "-";
+          }
+          const demandName = (record.demand_name ?? "").trim();
+          const demandType = (record.type ?? "").trim();
+          const dt = (record.dt ?? "").trim();
+          if (!demandName || !demandType || !dt) {
+            return "-";
+          }
+          return (
+            <Button
+              type="link"
+              size="small"
+              onClick={() =>
+                onViewHotSource({
+                  demandName,
+                  demandType,
+                  dt,
+                })
+              }
+            >
+              查看来源
+            </Button>
+          );
+        },
+      },
     ],
-    [sortBy, sortOrder]
+    [sortBy, sortOrder, onViewHotSource]
   );
 
   return (
@@ -591,7 +656,7 @@ function DemandPoolPanel() {
               columns={columns}
               dataSource={data.items}
               pagination={false}
-              scroll={{ x: 1060 }}
+              scroll={{ x: 1180 }}
               rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
               onChange={(_, __, sorter) => {
                 if (Array.isArray(sorter)) {
@@ -1248,13 +1313,35 @@ function MonthlyDemandPanel({ active }: { active: boolean }) {
 
 function App() {
   const [activeTab, setActiveTab] = useState("demand-pool");
+  const [hotSourceView, setHotSourceView] = useState<HotSourceViewParams | null>(() =>
+    readHotSourceViewFromUrl(),
+  );
+
+  useEffect(() => {
+    const onPopState = () => {
+      setHotSourceView(readHotSourceViewFromUrl());
+    };
+    window.addEventListener("popstate", onPopState);
+    return () => window.removeEventListener("popstate", onPopState);
+  }, []);
+
+  const openHotSourceView = useCallback((params: HotSourceViewParams) => {
+    const url = buildHotSourceViewUrl(params);
+    window.history.pushState(null, "", url);
+    setHotSourceView(params);
+  }, []);
+
+  const closeHotSourceView = useCallback(() => {
+    window.history.pushState(null, "", window.location.pathname);
+    setHotSourceView(null);
+  }, []);
 
   const tabItems = useMemo(
     () => [
       {
         key: "demand-pool",
         label: "需求池",
-        children: <DemandPoolPanel />,
+        children: <DemandPoolPanel onViewHotSource={openHotSourceView} />,
       },
       {
         key: "solar-calendar",
@@ -1278,9 +1365,20 @@ function App() {
         ),
       },
     ],
-    [activeTab],
+    [activeTab, openHotSourceView],
   );
 
+  if (hotSourceView) {
+    return (
+      <HotContentSourcePage
+        demandName={hotSourceView.demandName}
+        demandType={hotSourceView.demandType}
+        dt={hotSourceView.dt}
+        onBack={closeHotSourceView}
+      />
+    );
+  }
+
   return (
     <div className="page">
       <div className="hero">

+ 370 - 0
frontend/src/HotContentSourcePage.tsx

@@ -0,0 +1,370 @@
+import { useEffect, useMemo, useState } from "react";
+import { Alert, Button, Skeleton, Table, Tag, Typography } from "antd";
+import type { ColumnsType } from "antd/es/table";
+
+const API_BASE_URL =
+  import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
+
+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 ExportRow = {
+  id: number;
+  item_type: string;
+  item_text: string;
+  point_category: string;
+  matched_demand: string;
+  contribution_score: number | null;
+  wxindex_keyword: string;
+  all_hot_keywords: string;
+  wxindex_latest_score: number;
+  wxindex_trend: string;
+  contributes_to_sync: boolean;
+};
+
+type HotContentSourceDetail = {
+  wxindex_threshold: number;
+  sync_log: {
+    id: number;
+    partition_dt: string;
+    strategy: string;
+    demand_id: string;
+    demand_name: string;
+    demand_type: string;
+    record_id: number;
+    synced_at: string;
+    demand_id_verified: boolean;
+    hive_weight: number | null;
+  };
+  record: {
+    id: number;
+    source: string;
+    title: string;
+    article_title: string;
+    article_body: string;
+    hot_rank: number | null;
+    created_at: string;
+    contribution: Record<string, unknown>;
+    wxindex: Record<string, unknown>;
+    max_wxindex_score: number;
+    passes_wxindex_gate: boolean;
+    passes_point_gate: boolean;
+  };
+  export_rows: ExportRow[];
+};
+
+function formatNumber(value: unknown): string {
+  const number = Number(value);
+  if (!Number.isFinite(number)) {
+    return "-";
+  }
+  if (Math.abs(number) >= 10000) {
+    return `${(number / 10000).toFixed(1)}万`;
+  }
+  return String(Math.round(number));
+}
+
+function formatScore(value: unknown): string {
+  const number = Number(value);
+  if (!Number.isFinite(number)) {
+    return "-";
+  }
+  return String(Number(number.toFixed(2)));
+}
+
+function formatPartitionDt(value: string): string {
+  const text = value.trim();
+  if (/^\d{8}$/.test(text)) {
+    return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6)}`;
+  }
+  return text || "-";
+}
+
+function renderContributionSummary(contribution: Record<string, unknown>) {
+  const words = contribution["高贡献词列表"];
+  const points = contribution["点列表"];
+  let matchedWords = 0;
+  if (Array.isArray(words)) {
+    for (const item of words) {
+      if (
+        item &&
+        typeof item === "object" &&
+        Array.isArray((item as Record<string, unknown>)["匹配需求列表"]) &&
+        ((item as Record<string, unknown>)["匹配需求列表"] as unknown[]).length > 0
+      ) {
+        matchedWords += 1;
+      }
+    }
+  }
+  const wordCount = Array.isArray(words) ? words.length : 0;
+  const pointCount = Array.isArray(points) ? points.length : 0;
+  return (
+    <div className="hot-source-mini-metrics">
+      <span>高贡献词 {wordCount} 个</span>
+      <span>匹配到需求 {matchedWords} 个</span>
+      <span>点列表 {pointCount} 个</span>
+    </div>
+  );
+}
+
+function renderWxindexSummary(wxindex: Record<string, unknown>) {
+  const wx =
+    wxindex.wxindex && typeof wxindex.wxindex === "object"
+      ? (wxindex.wxindex as Record<string, unknown>)
+      : {};
+  const keyword = String(wx.keyword ?? wxindex.llm_selected_word ?? "-");
+  const trend = String(wx.trend ?? "-");
+  const latest = formatNumber(wx.latest_total_score);
+  const reason = String(wxindex.llm_reason ?? "").trim();
+  return (
+    <>
+      <div className="hot-source-mini-metrics">
+        <span>
+          关键词 <b>{keyword}</b>
+        </span>
+        <span>
+          趋势 <b>{trend}</b>
+        </span>
+        <span>
+          当前得分 <b>{latest}</b>
+        </span>
+      </div>
+      <div className="hot-source-mini-reason">{reason || "暂无 LLM 选词理由"}</div>
+    </>
+  );
+}
+
+type HotContentSourcePageProps = {
+  demandName: string;
+  demandType: string;
+  dt: string;
+  onBack: () => void;
+};
+
+export default function HotContentSourcePage({
+  demandName,
+  demandType,
+  dt,
+  onBack,
+}: HotContentSourcePageProps) {
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState("");
+  const [detail, setDetail] = useState<HotContentSourceDetail | null>(null);
+
+  useEffect(() => {
+    let cancelled = false;
+    const load = async () => {
+      setLoading(true);
+      setError("");
+      setDetail(null);
+      const resolvedBase = getResolvedApiBaseUrl();
+      const baseWithSlash = resolvedBase.endsWith("/")
+        ? resolvedBase
+        : `${resolvedBase}/`;
+      const url = new URL("demand-pool/hot-content-source", baseWithSlash);
+      url.searchParams.set("demand_name", demandName);
+      url.searchParams.set("demand_type", demandType);
+      url.searchParams.set("dt", dt);
+      try {
+        const response = await fetch(url.toString(), {
+          method: "GET",
+          headers: { Accept: "application/json" },
+        });
+        const payload = (await response.json()) as { detail?: string };
+        if (!response.ok) {
+          throw new Error(payload.detail ?? `HTTP ${response.status}`);
+        }
+        if (!cancelled) {
+          setDetail(payload as HotContentSourceDetail);
+        }
+      } catch (loadError) {
+        if (!cancelled) {
+          setError(
+            loadError instanceof Error ? loadError.message : "加载失败,请重试",
+          );
+        }
+      } finally {
+        if (!cancelled) {
+          setLoading(false);
+        }
+      }
+    };
+    void load();
+    return () => {
+      cancelled = true;
+    };
+  }, [demandName, demandType, dt]);
+
+  const exportColumns: ColumnsType<ExportRow> = useMemo(
+    () => [
+      { title: "ID", dataIndex: "id", width: 72 },
+      { title: "类型", dataIndex: "item_type", width: 72 },
+      {
+        title: "词条",
+        dataIndex: "item_text",
+        render: (value) => <b>{value || "-"}</b>,
+      },
+      {
+        title: "点类型",
+        dataIndex: "point_category",
+        width: 96,
+        render: (value) => value || "-",
+      },
+      {
+        title: "匹配需求",
+        dataIndex: "matched_demand",
+        render: (value) => value || "-",
+      },
+      {
+        title: "贡献分",
+        dataIndex: "contribution_score",
+        width: 88,
+        render: (value) => (value == null ? "-" : formatScore(value)),
+      },
+      {
+        title: "微信指数",
+        dataIndex: "wxindex_latest_score",
+        width: 96,
+        render: (value) => formatNumber(value),
+      },
+      {
+        title: "趋势",
+        dataIndex: "wxindex_trend",
+        width: 88,
+        render: (value) => value || "-",
+      },
+      {
+        title: "状态",
+        key: "status",
+        width: 100,
+        render: (_, row) => {
+          const matched = Boolean(row.matched_demand?.trim());
+          return (
+            <Tag color={matched ? "success" : "default"}>
+              {matched ? "匹配需求" : "未匹配"}
+            </Tag>
+          );
+        },
+      },
+    ],
+    [],
+  );
+
+  const record = detail?.record;
+  const syncLog = detail?.sync_log;
+  const bodyPreview = useMemo(() => {
+    const text = (record?.article_body ?? "").replace(/\s+/g, " ").trim();
+    if (!text) {
+      return "暂无正文";
+    }
+    return text.length > 160 ? `${text.slice(0, 160)}…` : text;
+  }, [record?.article_body]);
+
+  return (
+    <div className="page hot-source-page">
+      <div className="hero hot-source-hero">
+        <Button type="link" className="hot-source-back" onClick={onBack}>
+          ← 返回需求池
+        </Button>
+        <Typography.Title level={2} className="hero-title">
+          新热事件需求来源
+        </Typography.Title>
+        <Typography.Paragraph type="secondary" className="hot-source-subtitle">
+          需求:{demandName} · 类型:{demandType} · 分区:{formatPartitionDt(dt)}
+        </Typography.Paragraph>
+      </div>
+
+      {loading ? (
+        <div className="hot-source-card-shell">
+          <Skeleton active paragraph={{ rows: 12 }} />
+        </div>
+      ) : null}
+
+      {!loading && error ? (
+        <Alert type="error" showIcon message={error} className="hot-source-alert" />
+      ) : null}
+
+      {!loading && !error && detail && record && syncLog ? (
+        <article className="hot-source-title-card">
+          <header className="hot-source-title-header">
+            <div>
+              <Typography.Title level={3} className="hot-source-title-text">
+                {record.title || syncLog.demand_name}
+              </Typography.Title>
+              <div className="hot-source-title-meta">
+                <span>{record.source || "-"}</span>
+                <span>record #{record.id}</span>
+                <span>同步于 {syncLog.synced_at}</span>
+              </div>
+              <div className="hot-source-badges">
+                <Tag color={record.passes_wxindex_gate ? "success" : "warning"}>
+                  微信指数{record.passes_wxindex_gate ? "达标" : "未达标"}
+                </Tag>
+                <Tag color={record.passes_point_gate ? "success" : "warning"}>
+                  灵感/目的点{record.passes_point_gate ? "有匹配" : "无匹配"}
+                </Tag>
+                <Tag color={syncLog.demand_id_verified ? "success" : "error"}>
+                  demand_id{syncLog.demand_id_verified ? "校验通过" : "校验失败"}
+                </Tag>
+              </div>
+            </div>
+          </header>
+
+          <section className="hot-source-stage">
+            <div className="hot-source-sync-meta">
+              <span>
+                策略 <b>{syncLog.strategy}</b>
+              </span>
+              <span>
+                分区 <b>{formatPartitionDt(syncLog.partition_dt)}</b>
+              </span>
+              <span>
+                需求类型 <b>{syncLog.demand_type}</b>
+              </span>
+              <span>
+                Hive 权重 <b>{syncLog.hive_weight == null ? "-" : formatScore(syncLog.hive_weight)}</b>
+              </span>
+              <span>
+                门槛 <b>{formatNumber(detail.wxindex_threshold)}</b>
+              </span>
+            </div>
+            <div className="hot-source-record-subtitle">{record.article_title || "-"}</div>
+            <div className="hot-source-record-preview">{bodyPreview}</div>
+            <Typography.Title level={5} className="hot-source-section-label">
+              贡献匹配摘要
+            </Typography.Title>
+            {renderContributionSummary(record.contribution)}
+            <Typography.Title level={5} className="hot-source-section-label">
+              微信指数摘要
+            </Typography.Title>
+            {renderWxindexSummary(record.wxindex)}
+          </section>
+
+          <section className="hot-source-stage">
+            <Typography.Title level={4} className="hot-source-stage-title">
+              元素/短语
+            </Typography.Title>
+            <p className="hot-source-stage-hint">
+              高亮行表示参与生成该需求词的导出行。
+            </p>
+            <Table
+              rowKey="id"
+              size="small"
+              pagination={false}
+              columns={exportColumns}
+              dataSource={detail.export_rows}
+              scroll={{ x: 920 }}
+              rowClassName={(row) =>
+                row.contributes_to_sync ? "hot-source-export-highlight" : ""
+              }
+            />
+          </section>
+        </article>
+      ) : null}
+    </div>
+  );
+}

+ 140 - 0
frontend/src/styles.css

@@ -385,6 +385,146 @@ body {
   color: var(--accent);
 }
 
+.hot-source-page .hot-source-hero {
+  margin-bottom: 16px;
+}
+
+.hot-source-back {
+  padding-left: 0;
+  margin-bottom: 4px;
+}
+
+.hot-source-subtitle {
+  margin-bottom: 0 !important;
+}
+
+.hot-source-card-shell,
+.hot-source-title-card {
+  position: relative;
+  z-index: 1;
+  border: 1px solid #dbe3ef;
+  border-radius: 22px;
+  padding: 20px;
+  background: #fff;
+  box-shadow: 0 14px 36px rgba(21, 32, 51, 0.07);
+}
+
+.hot-source-alert {
+  position: relative;
+  z-index: 1;
+}
+
+.hot-source-title-header {
+  margin-bottom: 14px;
+}
+
+.hot-source-title-text {
+  margin: 0 0 8px !important;
+  line-height: 1.35 !important;
+}
+
+.hot-source-title-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.hot-source-title-meta span {
+  font-size: 12px;
+  color: #667085;
+  background: #f3f6fb;
+  border-radius: 999px;
+  padding: 4px 8px;
+}
+
+.hot-source-badges {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.hot-source-stage {
+  border-top: 1px solid #dbe3ef;
+  padding-top: 14px;
+  margin-top: 14px;
+}
+
+.hot-source-sync-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  font-size: 13px;
+  color: #334155;
+  margin-bottom: 12px;
+}
+
+.hot-source-sync-meta b {
+  color: #2563eb;
+}
+
+.hot-source-record-subtitle {
+  color: #667085;
+  margin-bottom: 8px;
+}
+
+.hot-source-record-preview {
+  padding: 10px 12px;
+  border-radius: 12px;
+  background: #fbfdff;
+  border: 1px solid #dbe3ef;
+  color: #334155;
+  line-height: 1.6;
+}
+
+.hot-source-section-label {
+  margin: 14px 0 8px !important;
+  color: #667085 !important;
+  font-size: 13px !important;
+}
+
+.hot-source-mini-metrics {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  font-size: 13px;
+}
+
+.hot-source-mini-metrics b {
+  color: #2563eb;
+}
+
+.hot-source-mini-reason {
+  margin-top: 8px;
+  padding: 8px 10px;
+  border-left: 3px solid #2563eb;
+  background: #eaf1ff;
+  color: #1e3a8a;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.hot-source-stage-title {
+  margin: 0 0 8px !important;
+}
+
+.hot-source-stage-hint {
+  margin: 0 0 10px;
+  color: #667085;
+  font-size: 13px;
+}
+
+.hot-source-stage tr.hot-source-export-highlight > td.ant-table-cell {
+  background: #fff4d6 !important;
+  border-top: 1px solid #fbbf24 !important;
+  border-bottom: 1px solid #fbbf24 !important;
+  font-weight: 600;
+}
+
+.hot-source-stage tr.hot-source-export-highlight > td.ant-table-cell:first-child {
+  box-shadow: inset 5px 0 0 #ea580c;
+}
+
 @media (max-width: 900px) {
   .strategy-select {
     width: 100%;