Ver Fonte

增加热点事件tab

xueyiming há 1 dia atrás
pai
commit
520756d9cb

+ 94 - 0
app/api/routes.py

@@ -13,6 +13,10 @@ from app.services.demand_pool_service import (
     query_strategy_options,
 )
 from app.services.hot_content_source_service import fetch_hot_content_source_detail
+from app.services.hot_content_demand_export_service import (
+    export_hot_content_demand_exports,
+    query_hot_content_demand_exports,
+)
 from app.services.element_search_service import (
     query_monthly_element_demands,
     query_same_period_last_year_element_demands,
@@ -49,6 +53,21 @@ MONTHLY_ELEMENT_DEMAND_EXPORT_COLUMNS: list[tuple[str, str]] = [
     ("频次", "frequency"),
 ]
 
+HOT_CONTENT_DEMAND_EXPORT_COLUMNS: list[tuple[str, str]] = [
+    ("来源", "source"),
+    ("热点标题", "hot_title"),
+    ("类型", "point_category"),
+    ("需求类型", "item_type_label"),
+    ("匹配需求", "matched_demand"),
+    ("是否成为需求", "is_as_demand_label"),
+    ("创建时间", "record_created_at"),
+    ("贡献分", "contribution_score"),
+    ("最高微信指数词", "wxindex_keyword"),
+    ("待选微信指数词", "all_hot_keywords"),
+    ("微信指数热度", "wxindex_latest_score"),
+    ("微信指数趋势", "wxindex_trend"),
+]
+
 
 def _export_timestamp() -> str:
     return datetime.now(SHANGHAI_TZ).strftime("%Y%m%d_%H%M%S")
@@ -399,6 +418,81 @@ async def get_demand_pool_hot_content_source(
         raise HTTPException(status_code=404, detail=str(exc)) from exc
 
 
+@router.get("/hot-content/demand-exports")
+async def get_hot_content_demand_exports(
+    start_dt: str | None = Query(
+        default=None,
+        description="开始日期(热点记录创建时间): yyyymmdd 或 yyyy-mm-dd,默认当天",
+    ),
+    end_dt: str | None = Query(
+        default=None,
+        description="结束日期(热点记录创建时间): yyyymmdd 或 yyyy-mm-dd,默认当天",
+    ),
+    is_as_demand: int | None = Query(
+        default=None,
+        description="是否成为需求:0 否,1 是;不传表示全部",
+    ),
+    item_type: str | None = Query(
+        default=None,
+        description="需求类型:词(特征点)、点(短语);不传表示全部",
+    ),
+    min_wxindex_latest_score: float | None = Query(
+        default=None,
+        ge=0,
+        description="微信指数热度下限(wxindex_latest_score >= 该值);不传表示不限制",
+    ),
+    page: int = Query(default=1, ge=1, description="页码,从 1 开始"),
+    page_size: int = Query(default=20, ge=1, le=200, description="每页条数"),
+) -> dict[str, object]:
+    try:
+        return query_hot_content_demand_exports(
+            start_dt=start_dt,
+            end_dt=end_dt,
+            is_as_demand=is_as_demand,
+            item_type=item_type,
+            min_wxindex_latest_score=min_wxindex_latest_score,
+            page=page,
+            page_size=page_size,
+        )
+    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
+
+
+@router.get("/hot-content/demand-exports/export")
+async def export_hot_content_demand_exports_api(
+    start_dt: str | None = Query(default=None, description="开始日期"),
+    end_dt: str | None = Query(default=None, description="结束日期"),
+    is_as_demand: int | None = Query(default=None, description="是否成为需求:0/1"),
+    item_type: str | None = Query(default=None, description="需求类型:词/点"),
+    min_wxindex_latest_score: float | None = Query(
+        default=None,
+        ge=0,
+        description="微信指数热度下限",
+    ),
+) -> StreamingResponse:
+    try:
+        items = export_hot_content_demand_exports(
+            start_dt=start_dt,
+            end_dt=end_dt,
+            is_as_demand=is_as_demand,
+            item_type=item_type,
+            min_wxindex_latest_score=min_wxindex_latest_score,
+        )
+    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
+    filename = f"新热事件查询_{_export_timestamp()}.xlsx"
+    return _excel_streaming_response(
+        items,
+        HOT_CONTENT_DEMAND_EXPORT_COLUMNS,
+        sheet_name="新热事件",
+        filename=filename,
+    )
+
+
 @router.get("/demand-pool/strategies")
 async def get_demand_pool_strategies(
     start_dt: str | None = Query(default=None, description="开始日期: yyyymmdd 或 yyyy-mm-dd"),

+ 238 - 0
app/services/hot_content_demand_export_service.py

@@ -0,0 +1,238 @@
+"""新热事件需求导出明细:按热点记录创建时间筛选 hot_content_demand_exports。"""
+
+from __future__ import annotations
+
+import re
+from datetime import date, datetime, timedelta
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from sqlalchemy import text
+
+from app.db.hot_content_mysql import HotContentSessionLocal
+
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+DATE_RE = re.compile(r"^\d{8}$")
+
+ITEM_TYPE_WORD = "词"
+ITEM_TYPE_POINT = "点"
+
+MAX_EXPORT_ROWS = 50_000
+
+_EXPORT_SELECT = """
+SELECT
+    e.id,
+    e.source,
+    e.hot_title,
+    e.point_category,
+    e.matched_demand,
+    e.is_as_demand,
+    e.contribution_score,
+    e.wxindex_keyword,
+    e.all_hot_keywords,
+    e.wxindex_latest_score,
+    e.wxindex_trend,
+    e.item_type,
+    r.created_at AS record_created_at
+FROM hot_content_demand_exports e
+INNER JOIN hot_content_records r ON r.id = e.record_id
+"""
+
+
+def _normalize_date(date_value: str | None) -> str | None:
+    if not date_value:
+        return None
+    normalized = str(date_value).replace("-", "").strip()
+    if not normalized:
+        return None
+    if not DATE_RE.match(normalized):
+        raise ValueError("date must be yyyymmdd or yyyy-mm-dd")
+    return normalized
+
+
+def _parse_yyyymmdd(value: str) -> date:
+    return datetime.strptime(value, "%Y%m%d").date()
+
+
+def _resolve_date_range(
+    start_dt: str | None,
+    end_dt: str | None,
+) -> tuple[datetime, datetime]:
+    """返回 [start_at, end_at_exclusive) 上海时区半开区间。"""
+    today = datetime.now(SHANGHAI_TZ).date()
+    normalized_start = _normalize_date(start_dt)
+    normalized_end = _normalize_date(end_dt)
+    start_day = _parse_yyyymmdd(normalized_start) if normalized_start else today
+    end_day = _parse_yyyymmdd(normalized_end) if normalized_end else today
+    if start_day > end_day:
+        raise ValueError("开始日期不能晚于结束日期")
+    start_at = datetime.combine(start_day, datetime.min.time(), tzinfo=SHANGHAI_TZ)
+    end_at_exclusive = datetime.combine(
+        end_day + timedelta(days=1),
+        datetime.min.time(),
+        tzinfo=SHANGHAI_TZ,
+    )
+    return start_at, end_at_exclusive
+
+
+def _item_type_label(item_type: str | None) -> str:
+    value = str(item_type or "").strip()
+    if value == ITEM_TYPE_WORD:
+        return "特征点"
+    if value == ITEM_TYPE_POINT:
+        return "短语"
+    return value or "-"
+
+
+def _normalize_item_type(item_type: str | None) -> str | None:
+    if item_type is None:
+        return None
+    value = str(item_type).strip()
+    if not value or value in {"全部", "all"}:
+        return None
+    if value in {ITEM_TYPE_WORD, "特征点"}:
+        return ITEM_TYPE_WORD
+    if value in {ITEM_TYPE_POINT, "短语"}:
+        return ITEM_TYPE_POINT
+    raise ValueError("item_type 须为 词、点、特征点、短语,或留空表示全部")
+
+
+def _build_filters(
+    *,
+    start_dt: str | None,
+    end_dt: str | None,
+    is_as_demand: int | None,
+    item_type: str | None,
+    min_wxindex_latest_score: float | None,
+) -> tuple[str, dict[str, object], datetime, datetime]:
+    start_at, end_at_exclusive = _resolve_date_range(start_dt, end_dt)
+    where_parts = [
+        "r.created_at >= :start_at",
+        "r.created_at < :end_at_exclusive",
+    ]
+    params: dict[str, object] = {
+        "start_at": start_at.replace(tzinfo=None),
+        "end_at_exclusive": end_at_exclusive.replace(tzinfo=None),
+    }
+    if is_as_demand is not None:
+        if is_as_demand not in (0, 1):
+            raise ValueError("is_as_demand 须为 0 或 1")
+        where_parts.append("e.is_as_demand = :is_as_demand")
+        params["is_as_demand"] = is_as_demand
+    normalized_item_type = _normalize_item_type(item_type)
+    if normalized_item_type:
+        where_parts.append("e.item_type = :item_type")
+        params["item_type"] = normalized_item_type
+    if min_wxindex_latest_score is not None:
+        if min_wxindex_latest_score < 0:
+            raise ValueError("min_wxindex_latest_score 不能为负数")
+        where_parts.append("e.wxindex_latest_score >= :min_wxindex_latest_score")
+        params["min_wxindex_latest_score"] = min_wxindex_latest_score
+    where_sql = f"WHERE {' AND '.join(where_parts)}"
+    return where_sql, params, start_at, end_at_exclusive
+
+
+def _row_to_dict(row: dict[str, Any]) -> dict[str, object]:
+    created_at = row.get("record_created_at")
+    if isinstance(created_at, datetime):
+        record_created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
+    else:
+        record_created_at = str(created_at) if created_at is not None else ""
+    is_as_demand_raw = row.get("is_as_demand")
+    is_as_demand_int = int(is_as_demand_raw) if is_as_demand_raw is not None else 0
+    contribution = row.get("contribution_score")
+    return {
+        "id": int(row["id"]),
+        "source": str(row.get("source") or ""),
+        "hot_title": str(row.get("hot_title") or ""),
+        "point_category": str(row.get("point_category") or ""),
+        "item_type": str(row.get("item_type") or ""),
+        "item_type_label": _item_type_label(row.get("item_type")),
+        "matched_demand": str(row.get("matched_demand") or ""),
+        "is_as_demand": is_as_demand_int,
+        "is_as_demand_label": "是" if is_as_demand_int == 1 else "否",
+        "contribution_score": float(contribution) if contribution is not None else None,
+        "wxindex_keyword": str(row.get("wxindex_keyword") or ""),
+        "all_hot_keywords": str(row.get("all_hot_keywords") or ""),
+        "wxindex_latest_score": float(row.get("wxindex_latest_score") or 0),
+        "wxindex_trend": str(row.get("wxindex_trend") or ""),
+        "item_type": str(row.get("item_type") or ""),
+        "record_created_at": record_created_at,
+    }
+
+
+def query_hot_content_demand_exports(
+    *,
+    start_dt: str | None = None,
+    end_dt: str | None = None,
+    is_as_demand: int | None = None,
+    item_type: str | None = None,
+    min_wxindex_latest_score: float | None = None,
+    page: int = 1,
+    page_size: int = 20,
+) -> dict[str, object]:
+    where_sql, params, _, _ = _build_filters(
+        start_dt=start_dt,
+        end_dt=end_dt,
+        is_as_demand=is_as_demand,
+        item_type=item_type,
+        min_wxindex_latest_score=min_wxindex_latest_score,
+    )
+    offset = (page - 1) * page_size
+    count_sql = text(
+        f"""
+        SELECT COUNT(*) AS cnt
+        FROM hot_content_demand_exports e
+        INNER JOIN hot_content_records r ON r.id = e.record_id
+        {where_sql}
+        """
+    )
+    list_sql = text(
+        f"""
+        {_EXPORT_SELECT}
+        {where_sql}
+        ORDER BY r.created_at ASC, e.id ASC
+        LIMIT :limit OFFSET :offset
+        """
+    )
+    list_params = {**params, "limit": page_size, "offset": offset}
+    with HotContentSessionLocal() as session:
+        total = int(session.execute(count_sql, params).scalar_one())
+        rows = session.execute(list_sql, list_params).mappings().all()
+    return {
+        "total": total,
+        "page": page,
+        "page_size": page_size,
+        "items": [_row_to_dict(dict(row)) for row in rows],
+    }
+
+
+def export_hot_content_demand_exports(
+    *,
+    start_dt: str | None = None,
+    end_dt: str | None = None,
+    is_as_demand: int | None = None,
+    item_type: str | None = None,
+    min_wxindex_latest_score: float | None = None,
+) -> list[dict[str, object]]:
+    where_sql, params, _, _ = _build_filters(
+        start_dt=start_dt,
+        end_dt=end_dt,
+        is_as_demand=is_as_demand,
+        item_type=item_type,
+        min_wxindex_latest_score=min_wxindex_latest_score,
+    )
+    export_sql = text(
+        f"""
+        {_EXPORT_SELECT}
+        {where_sql}
+        ORDER BY r.created_at ASC, e.id ASC
+        LIMIT :limit
+        """
+    )
+    export_params = {**params, "limit": MAX_EXPORT_ROWS + 1}
+    with HotContentSessionLocal() as session:
+        rows = session.execute(export_sql, export_params).mappings().all()
+    if len(rows) > MAX_EXPORT_ROWS:
+        raise ValueError(f"导出条数超过上限 {MAX_EXPORT_ROWS},请缩小日期或筛选范围")
+    return [_row_to_dict(dict(row)) for row in rows]

+ 426 - 17
frontend/src/App.tsx

@@ -177,10 +177,22 @@ function buildHotSourceViewUrl(params: HotSourceViewParams): string {
     demand_type: params.demandType,
     dt: params.dt,
   });
-  return `${window.location.pathname}?${search.toString()}`;
+  return `${window.location.origin}${window.location.pathname}?${search.toString()}`;
 }
 
-function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSourceViewParams) => void }) {
+function openHotSourceViewInNewTab(params: HotSourceViewParams) {
+  window.open(buildHotSourceViewUrl(params), "_blank", "noopener,noreferrer");
+}
+
+function closeHotSourceView() {
+  if (window.opener && !window.opener.closed) {
+    window.close();
+    return;
+  }
+  window.location.href = window.location.pathname;
+}
+
+function DemandPoolPanel() {
   const [strategies, setStrategies] = useState<string[]>([]);
   const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
     const today = dayjs();
@@ -503,7 +515,7 @@ function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSou
               type="link"
               size="small"
               onClick={() =>
-                onViewHotSource({
+                openHotSourceViewInNewTab({
                   demandName,
                   demandType,
                   dt,
@@ -516,7 +528,7 @@ function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSou
         },
       },
     ],
-    [sortBy, sortOrder, onViewHotSource]
+    [sortBy, sortOrder]
   );
 
   return (
@@ -1277,6 +1289,407 @@ function ElementDemandQueryPanel({
   );
 }
 
+type HotContentDemandExportItem = {
+  id: number;
+  source: string;
+  hot_title: string;
+  point_category: string;
+  item_type: string;
+  item_type_label: string;
+  matched_demand: string;
+  is_as_demand: number;
+  is_as_demand_label: string;
+  contribution_score: number | null;
+  wxindex_keyword: string;
+  all_hot_keywords: string;
+  wxindex_latest_score: number;
+  wxindex_trend: string;
+  item_type: string;
+  record_created_at: string;
+};
+
+type HotContentDemandExportResponse = {
+  total: number;
+  page: number;
+  page_size: number;
+  items: HotContentDemandExportItem[];
+};
+
+type IsAsDemandFilter = "all" | "yes" | "no";
+type ItemTypeFilter = "all" | "word" | "point";
+
+function HotContentDemandExportPanel({ active }: { active: boolean }) {
+  const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
+    const today = dayjs();
+    return [today, today];
+  });
+  const [isAsDemandFilter, setIsAsDemandFilter] = useState<IsAsDemandFilter>("all");
+  const [itemTypeFilter, setItemTypeFilter] = useState<ItemTypeFilter>("all");
+  const [appliedIsAsDemand, setAppliedIsAsDemand] = useState<IsAsDemandFilter>("all");
+  const [appliedItemType, setAppliedItemType] = useState<ItemTypeFilter>("all");
+  const [minWxindexInput, setMinWxindexInput] = useState<number | null>(null);
+  const [appliedMinWxindex, setAppliedMinWxindex] = useState<number | null>(null);
+  const [currentPage, setCurrentPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+  const [refreshTick, setRefreshTick] = useState(0);
+  const [loading, setLoading] = useState(false);
+  const [exporting, setExporting] = useState(false);
+  const [hasLoaded, setHasLoaded] = useState(false);
+  const [error, setError] = useState("");
+  const [data, setData] = useState<HotContentDemandExportResponse>({
+    total: 0,
+    page: 1,
+    page_size: pageSize,
+    items: [],
+  });
+
+  const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? "";
+  const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? "";
+  const dateRangeInvalid =
+    Boolean(startDate) && Boolean(endDate) && startDate > endDate;
+
+  const appendFiltersToUrl = (url: URL) => {
+    if (startDate) {
+      url.searchParams.set("start_dt", startDate);
+    }
+    if (endDate) {
+      url.searchParams.set("end_dt", endDate);
+    }
+    if (appliedIsAsDemand === "yes") {
+      url.searchParams.set("is_as_demand", "1");
+    } else if (appliedIsAsDemand === "no") {
+      url.searchParams.set("is_as_demand", "0");
+    }
+    if (appliedItemType === "word") {
+      url.searchParams.set("item_type", "词");
+    } else     if (appliedItemType === "point") {
+      url.searchParams.set("item_type", "点");
+    }
+    if (appliedMinWxindex !== null) {
+      url.searchParams.set("min_wxindex_latest_score", String(appliedMinWxindex));
+    }
+  };
+
+  const buildRequestUrl = (page: number, size: number = pageSize) => {
+    const resolvedBase = getResolvedApiBaseUrl();
+    const baseWithSlash = resolvedBase.endsWith("/")
+      ? resolvedBase
+      : `${resolvedBase}/`;
+    const url = new URL("hot-content/demand-exports", baseWithSlash);
+    appendFiltersToUrl(url);
+    url.searchParams.set("page", String(page));
+    url.searchParams.set("page_size", String(size));
+    return url.toString();
+  };
+
+  const buildExportUrl = () => {
+    const resolvedBase = getResolvedApiBaseUrl();
+    const baseWithSlash = resolvedBase.endsWith("/")
+      ? resolvedBase
+      : `${resolvedBase}/`;
+    const url = new URL("hot-content/demand-exports/export", baseWithSlash);
+    appendFiltersToUrl(url);
+    return url.toString();
+  };
+
+  const queryKey = JSON.stringify({
+    startDate,
+    endDate,
+    appliedIsAsDemand,
+    appliedItemType,
+    appliedMinWxindex,
+    currentPage,
+    pageSize,
+    refreshTick,
+    active,
+  });
+
+  const fetchData = async (page: number, size: number = pageSize) => {
+    setLoading(true);
+    setError("");
+    try {
+      const response = await fetch(buildRequestUrl(page, size), {
+        method: "GET",
+        headers: { Accept: "application/json" },
+      });
+      if (!response.ok) {
+        const detail = await response.text();
+        throw new Error(detail || `HTTP ${response.status}`);
+      }
+      const payload = (await response.json()) as HotContentDemandExportResponse;
+      setData(payload);
+    } catch (queryError) {
+      setError(
+        queryError instanceof Error ? queryError.message : "查询失败,请重试",
+      );
+    } finally {
+      setLoading(false);
+      setHasLoaded(true);
+    }
+  };
+
+  useEffect(() => {
+    if (!active || dateRangeInvalid) {
+      return;
+    }
+    void fetchData(currentPage, pageSize);
+  }, [queryKey, dateRangeInvalid]);
+
+  const handleSubmit = () => {
+    if (dateRangeInvalid) {
+      return;
+    }
+    setAppliedIsAsDemand(isAsDemandFilter);
+    setAppliedItemType(itemTypeFilter);
+    setAppliedMinWxindex(minWxindexInput);
+    setCurrentPage(1);
+    setRefreshTick((value) => value + 1);
+  };
+
+  const handleExport = async () => {
+    if (dateRangeInvalid) {
+      return;
+    }
+    setExporting(true);
+    try {
+      await downloadExcelExport(buildExportUrl(), "新热事件查询.xlsx");
+      message.success("导出成功");
+    } catch {
+      message.error("导出失败,请重试");
+    } finally {
+      setExporting(false);
+    }
+  };
+
+  const resetFilters = () => {
+    const today = dayjs();
+    setDateRange([today, today]);
+    setIsAsDemandFilter("all");
+    setItemTypeFilter("all");
+    setAppliedIsAsDemand("all");
+    setAppliedItemType("all");
+    setMinWxindexInput(null);
+    setAppliedMinWxindex(null);
+    setCurrentPage(1);
+    setRefreshTick((value) => value + 1);
+  };
+
+  const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
+
+  const columns: ColumnsType<HotContentDemandExportItem> = useMemo(
+    () => [
+      {
+        title: "来源",
+        dataIndex: "source",
+        width: 100,
+        ellipsis: true,
+        fixed: "left",
+      },
+      {
+        title: "热点标题",
+        dataIndex: "hot_title",
+        width: 220,
+        ellipsis: true,
+        fixed: "left",
+      },
+      {
+        title: "类型",
+        dataIndex: "point_category",
+        width: 100,
+        fixed: "left",
+      },
+      {
+        title: "需求类型",
+        dataIndex: "item_type_label",
+        width: 90,
+        align: "center",
+        fixed: "left",
+      },
+      {
+        title: "匹配需求",
+        dataIndex: "matched_demand",
+        width: 200,
+        ellipsis: true,
+        fixed: "left",
+      },
+      {
+        title: "是否成为需求",
+        dataIndex: "is_as_demand_label",
+        width: 120,
+        align: "center",
+        fixed: "left",
+      },
+      {
+        title: "创建时间",
+        dataIndex: "record_created_at",
+        width: 170,
+      },
+      {
+        title: "贡献分",
+        dataIndex: "contribution_score",
+        width: 100,
+        align: "right",
+        render: (v: number | null) =>
+          v === null || v === undefined ? "-" : Number(v).toFixed(2),
+      },
+      { title: "最高微信指数词", dataIndex: "wxindex_keyword", width: 160, ellipsis: true },
+      { title: "待选微信指数词", dataIndex: "all_hot_keywords", width: 200, ellipsis: true },
+      {
+        title: "微信指数热度",
+        dataIndex: "wxindex_latest_score",
+        width: 120,
+        align: "right",
+        render: (v: number) => Number(v ?? 0).toLocaleString(),
+      },
+      { title: "微信指数趋势", dataIndex: "wxindex_trend", width: 110 },
+    ],
+    [],
+  );
+
+  return (
+    <div className="panel-sheet">
+      <section className="panel-section panel-section--filters">
+        <header className="panel-section-head">
+          <span className="panel-section-accent" aria-hidden />
+          <Typography.Title level={5} className="panel-section-title">
+            筛选条件
+          </Typography.Title>
+        </header>
+        <Form layout="vertical" onFinish={handleSubmit} className="filter-form">
+          <div className="filter-row second-row hot-content-demand-filter-row">
+            <Form.Item label="日期区间(热点记录创建时间)">
+              <DatePicker.RangePicker
+                value={dateRange}
+                onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
+                allowClear={false}
+              />
+            </Form.Item>
+            <Form.Item label="是否是需求">
+              <Select<IsAsDemandFilter>
+                className="hot-content-filter-select"
+                value={isAsDemandFilter}
+                onChange={setIsAsDemandFilter}
+                options={[
+                  { label: "全部", value: "all" },
+                  { label: "是", value: "yes" },
+                  { label: "否", value: "no" },
+                ]}
+              />
+            </Form.Item>
+            <Form.Item label="需求类型">
+              <Select<ItemTypeFilter>
+                className="hot-content-filter-select"
+                value={itemTypeFilter}
+                onChange={setItemTypeFilter}
+                options={[
+                  { label: "全部", value: "all" },
+                  { label: "特征点", value: "word" },
+                  { label: "短语", value: "point" },
+                ]}
+              />
+            </Form.Item>
+            <Form.Item label="微信指数热度 ≥">
+              <InputNumber
+                className="hot-content-wxindex-input"
+                min={0}
+                step={10000}
+                placeholder="不限制"
+                value={minWxindexInput}
+                onChange={(v) => setMinWxindexInput(v ?? null)}
+              />
+            </Form.Item>
+            <Form.Item label=" ">
+              <Button
+                type="primary"
+                htmlType="submit"
+                loading={loading}
+                disabled={dateRangeInvalid}
+              >
+                查询
+              </Button>
+            </Form.Item>
+            <Form.Item label=" ">
+              <Button type="default" onClick={resetFilters}>
+                重置
+              </Button>
+            </Form.Item>
+          </div>
+        </Form>
+        {dateRangeInvalid ? (
+          <Alert
+            style={{ marginTop: 12 }}
+            type="error"
+            showIcon
+            message="开始日期不能晚于结束日期"
+          />
+        ) : null}
+        {error ? (
+          <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
+        ) : null}
+      </section>
+
+      <section className="panel-section panel-section--table">
+        <div className="table-toolbar">
+          <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
+            新热事件明细
+          </Typography.Title>
+          <Space size={8} wrap className="table-toolbar-meta">
+            <span className="meta-chip">共 {data.total} 条</span>
+            <span className="meta-chip">
+              第 {currentPage} / {totalPages} 页
+            </span>
+            <Button
+              type="default"
+              loading={exporting}
+              disabled={dateRangeInvalid || loading}
+              onClick={() => void handleExport()}
+            >
+              导出 Excel
+            </Button>
+          </Space>
+        </div>
+        {loading && !hasLoaded ? (
+          <Skeleton active paragraph={{ rows: 10 }} />
+        ) : (
+          <div className="table-wrap">
+            <Table
+              rowKey="id"
+              loading={loading}
+              columns={columns}
+              dataSource={data.items}
+              pagination={false}
+              scroll={{ x: 1690 }}
+              rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
+            />
+          </div>
+        )}
+        <div className="panel-footer">
+          <Pagination
+            current={currentPage}
+            total={data.total}
+            pageSize={pageSize}
+            showSizeChanger
+            pageSizeOptions={["10", "20", "50", "100"]}
+            showQuickJumper
+            showTotal={(total) => `共 ${total} 条`}
+            onChange={(page, size) => {
+              const nextSize = size ?? pageSize;
+              setCurrentPage(page);
+              if (nextSize !== pageSize) {
+                setPageSize(nextSize);
+              }
+            }}
+            onShowSizeChange={(_, size) => {
+              setPageSize(size);
+              setCurrentPage(1);
+            }}
+          />
+        </div>
+      </section>
+    </div>
+  );
+}
+
 function SolarCalendarPanel({ active }: { active: boolean }) {
   return (
     <ElementDemandQueryPanel
@@ -1325,23 +1738,12 @@ function App() {
     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 onViewHotSource={openHotSourceView} />,
+        children: <DemandPoolPanel />,
       },
       {
         key: "solar-calendar",
@@ -1364,8 +1766,15 @@ function App() {
           <MonthlyDemandPanel active={activeTab === "monthly-demand"} />
         ),
       },
+      {
+        key: "hot-content-demand",
+        label: "新热事件查询",
+        children: (
+          <HotContentDemandExportPanel active={activeTab === "hot-content-demand"} />
+        ),
+      },
     ],
-    [activeTab, openHotSourceView],
+    [activeTab],
   );
 
   if (hotSourceView) {

+ 7 - 1
frontend/src/HotContentSourcePage.tsx

@@ -222,6 +222,12 @@ export default function HotContentSourcePage({
         dataIndex: "matched_demand",
         render: (value) => value || "-",
       },
+      {
+        title: "微信热点词",
+        dataIndex: "wxindex_keyword",
+        width: 120,
+        render: (value) => value || "-",
+      },
       {
         title: "贡献分",
         dataIndex: "contribution_score",
@@ -387,7 +393,7 @@ export default function HotContentSourcePage({
               pagination={false}
               columns={exportColumns}
               dataSource={detail.export_rows}
-              scroll={{ x: 920 }}
+              scroll={{ x: 1040 }}
               rowClassName={(row) =>
                 row.contributes_to_sync ? "hot-source-export-highlight" : ""
               }

+ 30 - 0
frontend/src/styles.css

@@ -310,6 +310,36 @@ body {
   max-width: 100%;
 }
 
+/* 新热事件查询:筛选项底对齐,标签区等高 */
+.hot-content-demand-filter-row {
+  align-items: flex-end;
+}
+
+.hot-content-demand-filter-row > .ant-form-item {
+  flex: 0 0 auto;
+  margin-bottom: 0 !important;
+}
+
+.hot-content-demand-filter-row > .ant-form-item .ant-form-item-label {
+  min-height: 44px;
+  display: flex;
+  align-items: flex-end;
+  padding-bottom: 8px;
+}
+
+.hot-content-demand-filter-row > .ant-form-item .ant-form-item-label > label {
+  height: auto;
+  white-space: normal;
+  line-height: 1.35;
+}
+
+.hot-content-demand-filter-row .hot-content-filter-select,
+.hot-content-demand-filter-row .hot-content-wxindex-input {
+  width: 168px !important;
+  min-width: 168px;
+  max-width: 100%;
+}
+
 .strategy-item {
   flex: 1;
   width: 100%;