فهرست منبع

增加导出功能

xueyiming 2 روز پیش
والد
کامیت
23e48b8ba5
4فایلهای تغییر یافته به همراه416 افزوده شده و 9 حذف شده
  1. 193 0
      app/api/routes.py
  2. 83 8
      app/services/demand_pool_service.py
  3. 138 0
      frontend/src/App.tsx
  4. 2 1
      requirements.txt

+ 193 - 0
app/api/routes.py

@@ -1,8 +1,14 @@
+from datetime import datetime
+from io import BytesIO
+from zoneinfo import ZoneInfo
+
 from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import StreamingResponse
 
 from app.core.config import settings
 from app.scheduler.manager import scheduler
 from app.services.demand_pool_service import (
+    export_demand_pool_records,
     query_demand_pool_records,
     query_strategy_options,
 )
@@ -12,9 +18,49 @@ from app.services.element_search_service import (
     query_same_period_last_year_lunar_element_demands,
     query_video_decode_url2_for_today,
 )
+from app.utils.excel_export import build_content_disposition, rows_to_excel_bytes
 
 router = APIRouter()
 
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+
+DEMAND_POOL_EXPORT_COLUMNS: list[tuple[str, str]] = [
+    ("ID", "id"),
+    ("策略名", "strategy"),
+    ("需求名称", "demand_name"),
+    ("需求类型", "type"),
+    ("权重", "weight"),
+    ("视频数量", "video_count"),
+    ("日期", "dt"),
+]
+
+ELEMENT_DEMAND_EXPORT_COLUMNS: list[tuple[str, str]] = [
+    ("策略", "strategy"),
+    ("特征点名称", "demand_name"),
+    ("权重", "weight"),
+    ("视频数", "video_count"),
+    ("视频列表", "video_list"),
+]
+
+
+def _export_timestamp() -> str:
+    return datetime.now(SHANGHAI_TZ).strftime("%Y%m%d_%H%M%S")
+
+
+def _excel_streaming_response(
+    rows: list[dict[str, object]],
+    columns: list[tuple[str, str]],
+    *,
+    sheet_name: str,
+    filename: str,
+) -> StreamingResponse:
+    content = rows_to_excel_bytes(rows, columns, sheet_name=sheet_name)
+    return StreamingResponse(
+        BytesIO(content),
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": build_content_disposition(filename)},
+    )
+
 
 @router.get("/health")
 async def health_check() -> dict[str, str]:
@@ -61,6 +107,39 @@ async def query_demand_pool(
     )
 
 
+@router.get("/demand-pool/export")
+async def export_demand_pool(
+    strategy: list[str] | None = Query(default=None, description="策略,支持多选"),
+    start_dt: str | None = Query(default=None, description="开始日期: yyyymmdd 或 yyyy-mm-dd"),
+    end_dt: str | None = Query(default=None, description="结束日期: yyyymmdd 或 yyyy-mm-dd"),
+    demand_name: str | None = Query(
+        default=None,
+        description="需求名称包含该子串则保留;空或未传则不筛选",
+    ),
+    min_weight: float | None = Query(default=None, description="最小权重"),
+    max_weight: float | None = Query(default=None, description="最大权重"),
+    sort_by: str | None = Query(default="weight", description="排序字段"),
+    sort_order: str | None = Query(default="desc", description="排序方向: asc 或 desc"),
+) -> StreamingResponse:
+    items = export_demand_pool_records(
+        strategies=strategy,
+        start_dt=start_dt,
+        end_dt=end_dt,
+        demand_name=demand_name,
+        min_weight=min_weight,
+        max_weight=max_weight,
+        sort_by=sort_by,
+        sort_order=sort_order,
+    )
+    filename = f"需求池_{_export_timestamp()}.xlsx"
+    return _excel_streaming_response(
+        items,
+        DEMAND_POOL_EXPORT_COLUMNS,
+        sheet_name="需求明细",
+        filename=filename,
+    )
+
+
 @router.get("/element-demands/solar-calendar")
 async def get_element_demands_solar_calendar(
     period_days: int = Query(
@@ -91,6 +170,42 @@ async def get_element_demands_solar_calendar(
     return {"items": items}
 
 
+@router.get("/element-demands/solar-calendar/export")
+async def export_element_demands_solar_calendar(
+    period_days: int = Query(
+        ...,
+        ge=0,
+        description="区间天数(含去年阳历今日);0 表示仅当日分区",
+    ),
+    view_pv_count: int = Query(
+        ...,
+        ge=0,
+        description="当日分发曝光 pv 下限(video_dimension_detail_add_column)",
+    ),
+    min_contribution_score: float = Query(
+        ...,
+        description="贡献分下限(dwd_topic_decode_result_detail_di)",
+    ),
+    rov_avg: float = Query(
+        ...,
+        description="按原始元素分组后的平均 ROV 下限(HAVING)",
+    ),
+) -> StreamingResponse:
+    items = query_same_period_last_year_element_demands(
+        period_days=period_days,
+        view_pv_count=view_pv_count,
+        min_contribution_score=min_contribution_score,
+        rov_avg=rov_avg,
+    )
+    filename = f"去年同期阳历特征点_{_export_timestamp()}.xlsx"
+    return _excel_streaming_response(
+        items,
+        ELEMENT_DEMAND_EXPORT_COLUMNS,
+        sheet_name="特征点明细",
+        filename=filename,
+    )
+
+
 @router.get("/element-demands/lunar-calendar")
 async def get_element_demands_lunar_calendar(
     period_days: int = Query(
@@ -121,6 +236,42 @@ async def get_element_demands_lunar_calendar(
     return {"items": items}
 
 
+@router.get("/element-demands/lunar-calendar/export")
+async def export_element_demands_lunar_calendar(
+    period_days: int = Query(
+        ...,
+        ge=0,
+        description="区间天数(含去年阴历今日);0 表示仅当日分区",
+    ),
+    view_pv_count: int = Query(
+        ...,
+        ge=0,
+        description="当日分发曝光 pv 下限(video_dimension_detail_add_column)",
+    ),
+    min_contribution_score: float = Query(
+        ...,
+        description="贡献分下限(dwd_topic_decode_result_detail_di)",
+    ),
+    rov_avg: float = Query(
+        ...,
+        description="按原始元素分组后的平均 ROV 下限(HAVING)",
+    ),
+) -> StreamingResponse:
+    items = query_same_period_last_year_lunar_element_demands(
+        period_days=period_days,
+        view_pv_count=view_pv_count,
+        min_contribution_score=min_contribution_score,
+        rov_avg=rov_avg,
+    )
+    filename = f"去年同期阴历特征点_{_export_timestamp()}.xlsx"
+    return _excel_streaming_response(
+        items,
+        ELEMENT_DEMAND_EXPORT_COLUMNS,
+        sheet_name="特征点明细",
+        filename=filename,
+    )
+
+
 @router.get("/element-demands/monthly")
 async def get_element_demands_monthly(
     view_pv_count: int = Query(
@@ -157,6 +308,48 @@ async def get_element_demands_monthly(
     return {"items": items}
 
 
+@router.get("/element-demands/monthly/export")
+async def export_element_demands_monthly(
+    view_pv_count: int = Query(
+        ...,
+        ge=0,
+        description="当日分发曝光 pv 下限(video_dimension_detail_add_column 单日行)",
+    ),
+    month_total_pv_threshold: float = Query(
+        ...,
+        ge=0,
+        description="视频单月累计分发曝光 PV 和的下限(严格大于该值才保留)",
+    ),
+    min_contribution_score: float = Query(
+        ...,
+        description="贡献分下限(dwd_topic_decode_result_detail_di)",
+    ),
+    rov_avg: float = Query(
+        ...,
+        description="元素单月平均 ROV 下限(按月聚合后再汇总)",
+    ),
+    min_frequency: int = Query(
+        ...,
+        ge=0,
+        description="元素在回溯窗口内满足条件的月份数下限",
+    ),
+) -> StreamingResponse:
+    items = query_monthly_element_demands(
+        view_pv_count=view_pv_count,
+        month_total_pv_threshold=month_total_pv_threshold,
+        min_contribution_score=min_contribution_score,
+        rov_avg=rov_avg,
+        min_frequency=min_frequency,
+    )
+    filename = f"逐月特征点_{_export_timestamp()}.xlsx"
+    return _excel_streaming_response(
+        items,
+        ELEMENT_DEMAND_EXPORT_COLUMNS,
+        sheet_name="特征点明细",
+        filename=filename,
+    )
+
+
 @router.get("/videos/decode-url")
 async def get_video_decode_page_url(
     vid: str = Query(

+ 83 - 8
app/services/demand_pool_service.py

@@ -20,7 +20,10 @@ def _normalize_date(date_value: str | None) -> str | None:
     return normalized
 
 
-def query_demand_pool_records(
+MAX_EXPORT_ROWS = 50_000
+
+
+def _build_demand_pool_filters(
     strategies: list[str] | None = None,
     start_dt: str | None = None,
     end_dt: str | None = None,
@@ -29,13 +32,7 @@ def query_demand_pool_records(
     max_weight: float | None = None,
     sort_by: str | None = None,
     sort_order: str | None = None,
-    page: int = 1,
-    page_size: int = 20,
-) -> dict[str, object]:
-    table_name = settings.demand_pool_target_table
-    if not IDENTIFIER_RE.match(table_name):
-        raise ValueError("invalid table name in settings")
-
+) -> tuple[str, str, dict[str, object]]:
     where_parts: list[str] = []
     params: dict[str, object] = {}
 
@@ -80,6 +77,35 @@ def query_demand_pool_records(
     order_column = sort_column_map.get(sort_by or "", "weight")
     order_direction = "ASC" if (sort_order or "").lower() == "asc" else "DESC"
     order_sql = f"ORDER BY {order_column} {order_direction}"
+    return where_sql, order_sql, params
+
+
+def query_demand_pool_records(
+    strategies: list[str] | None = None,
+    start_dt: str | None = None,
+    end_dt: str | None = None,
+    demand_name: str | None = None,
+    min_weight: float | None = None,
+    max_weight: float | None = None,
+    sort_by: str | None = None,
+    sort_order: str | None = None,
+    page: int = 1,
+    page_size: int = 20,
+) -> dict[str, object]:
+    table_name = settings.demand_pool_target_table
+    if not IDENTIFIER_RE.match(table_name):
+        raise ValueError("invalid table name in settings")
+
+    where_sql, order_sql, params = _build_demand_pool_filters(
+        strategies=strategies,
+        start_dt=start_dt,
+        end_dt=end_dt,
+        demand_name=demand_name,
+        min_weight=min_weight,
+        max_weight=max_weight,
+        sort_by=sort_by,
+        sort_order=sort_order,
+    )
     offset = (page - 1) * page_size
     page_params: dict[str, object] = {
         **params,
@@ -127,6 +153,55 @@ def query_demand_pool_records(
     }
 
 
+def export_demand_pool_records(
+    strategies: list[str] | None = None,
+    start_dt: str | None = None,
+    end_dt: str | None = None,
+    demand_name: str | None = None,
+    min_weight: float | None = None,
+    max_weight: float | None = None,
+    sort_by: str | None = None,
+    sort_order: str | None = None,
+    max_rows: int = MAX_EXPORT_ROWS,
+) -> list[dict[str, object]]:
+    table_name = settings.demand_pool_target_table
+    if not IDENTIFIER_RE.match(table_name):
+        raise ValueError("invalid table name in settings")
+
+    where_sql, order_sql, params = _build_demand_pool_filters(
+        strategies=strategies,
+        start_dt=start_dt,
+        end_dt=end_dt,
+        demand_name=demand_name,
+        min_weight=min_weight,
+        max_weight=max_weight,
+        sort_by=sort_by,
+        sort_order=sort_order,
+    )
+    export_params: dict[str, object] = {**params, "max_rows": max_rows}
+    query_sql = text(
+        f"""
+        SELECT
+            id,
+            strategy,
+            demand_name,
+            weight,
+            `type`,
+            video_count,
+            dt
+        FROM {table_name}
+        {where_sql}
+        {order_sql}
+        LIMIT :max_rows
+        """
+    )
+
+    with SessionLocal() as session:
+        rows = session.execute(query_sql, export_params).mappings().all()
+
+    return [dict(row) for row in rows]
+
+
 def query_strategy_options(
     start_dt: str | None = None,
     end_dt: str | None = None,

+ 138 - 0
frontend/src/App.tsx

@@ -89,6 +89,39 @@ const getResolvedApiBaseUrl = () => {
   return new URL(API_BASE_URL, window.location.origin).toString();
 };
 
+function parseFilenameFromContentDisposition(header: string | null): string | null {
+  if (!header) {
+    return null;
+  }
+  const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
+  if (utf8Match?.[1]) {
+    try {
+      return decodeURIComponent(utf8Match[1]);
+    } catch {
+      return utf8Match[1];
+    }
+  }
+  const asciiMatch = header.match(/filename="([^"]+)"/i);
+  return asciiMatch?.[1] ?? null;
+}
+
+async function downloadExcelExport(url: string, defaultFilename: string) {
+  const response = await fetch(url, { method: "GET" });
+  if (!response.ok) {
+    throw new Error(`HTTP ${response.status}`);
+  }
+  const blob = await response.blob();
+  const filename =
+    parseFilenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
+    defaultFilename;
+  const objectUrl = URL.createObjectURL(blob);
+  const link = document.createElement("a");
+  link.href = objectUrl;
+  link.download = filename;
+  link.click();
+  URL.revokeObjectURL(objectUrl);
+}
+
 /** 票圈后台:视频详情页(新标签打开) */
 const CMS_VIDEO_POST_DETAIL_BASE =
   "https://admin.piaoquantv.com/cms/post-detail/";
@@ -112,6 +145,7 @@ function DemandPoolPanel() {
   const [sortBy, setSortBy] = useState("weight");
   const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
   const [loading, setLoading] = useState(false);
+  const [exporting, setExporting] = useState(false);
   const [hasLoaded, setHasLoaded] = useState(false);
   const [loadingStrategies, setLoadingStrategies] = useState(false);
   const [error, setError] = useState("");
@@ -174,6 +208,51 @@ function DemandPoolPanel() {
     return url.toString();
   };
 
+  const buildExportUrl = () => {
+    const resolvedBase = getResolvedApiBaseUrl();
+    const baseWithSlash = resolvedBase.endsWith("/")
+      ? resolvedBase
+      : `${resolvedBase}/`;
+    const url = new URL("demand-pool/export", baseWithSlash);
+    for (const strategyValue of strategies) {
+      url.searchParams.append("strategy", strategyValue);
+    }
+    if (startDate) {
+      url.searchParams.set("start_dt", startDate);
+    }
+    if (endDate) {
+      url.searchParams.set("end_dt", endDate);
+    }
+    if (appliedMinWeight !== null) {
+      url.searchParams.set("min_weight", String(appliedMinWeight));
+    }
+    if (appliedMaxWeight !== null) {
+      url.searchParams.set("max_weight", String(appliedMaxWeight));
+    }
+    const trimmedDemandName = appliedDemandName.trim();
+    if (trimmedDemandName) {
+      url.searchParams.set("demand_name", trimmedDemandName);
+    }
+    url.searchParams.set("sort_by", sortBy);
+    url.searchParams.set("sort_order", sortOrder);
+    return url.toString();
+  };
+
+  const handleExport = async () => {
+    if (dateRangeInvalid || weightRangeInvalid) {
+      return;
+    }
+    setExporting(true);
+    try {
+      await downloadExcelExport(buildExportUrl(), "需求池.xlsx");
+      message.success("导出成功");
+    } catch {
+      message.error("导出失败,请重试");
+    } finally {
+      setExporting(false);
+    }
+  };
+
   const buildStrategyUrl = () => {
     const resolvedBase = getResolvedApiBaseUrl();
     const baseWithSlash = resolvedBase.endsWith("/")
@@ -471,6 +550,14 @@ function DemandPoolPanel() {
             <span className="meta-chip">
               第 {currentPage} / {totalPages} 页
             </span>
+            <Button
+              type="default"
+              loading={exporting}
+              disabled={dateRangeInvalid || weightRangeInvalid || loading}
+              onClick={() => void handleExport()}
+            >
+              导出 Excel
+            </Button>
           </Space>
         </div>
         {loading && !hasLoaded ? (
@@ -554,6 +641,7 @@ function ElementDemandQueryPanel({
 
   const [items, setItems] = useState<ElementDemandItem[]>([]);
   const [loading, setLoading] = useState(false);
+  const [exporting, setExporting] = useState(false);
   const [hasLoaded, setHasLoaded] = useState(false);
   const [error, setError] = useState("");
 
@@ -633,6 +721,48 @@ function ElementDemandQueryPanel({
     rovAvg,
   ]);
 
+  const buildExportUrl = useCallback(() => {
+    const resolvedBase = getResolvedApiBaseUrl();
+    const baseWithSlash = resolvedBase.endsWith("/")
+      ? resolvedBase
+      : `${resolvedBase}/`;
+    const url = new URL(`${apiPath}/export`, baseWithSlash);
+    if (mode === "period") {
+      url.searchParams.set("period_days", String(periodDays));
+    } else {
+      url.searchParams.set(
+        "month_total_pv_threshold",
+        String(monthTotalPvThreshold)
+      );
+      url.searchParams.set("min_frequency", String(minFrequency));
+    }
+    url.searchParams.set("view_pv_count", String(viewPvCount));
+    url.searchParams.set("min_contribution_score", String(minContributionScore));
+    url.searchParams.set("rov_avg", String(rovAvg));
+    return url.toString();
+  }, [
+    apiPath,
+    mode,
+    periodDays,
+    monthTotalPvThreshold,
+    minFrequency,
+    viewPvCount,
+    minContributionScore,
+    rovAvg,
+  ]);
+
+  const handleExport = async () => {
+    setExporting(true);
+    try {
+      await downloadExcelExport(buildExportUrl(), "特征点.xlsx");
+      message.success("导出成功");
+    } catch {
+      message.error("导出失败,请重试");
+    } finally {
+      setExporting(false);
+    }
+  };
+
   const fetchAll = useCallback(async () => {
     setLoading(true);
     setError("");
@@ -850,6 +980,14 @@ function ElementDemandQueryPanel({
             <span className="meta-chip">
               第 {page} / {totalPages} 页
             </span>
+            <Button
+              type="default"
+              loading={exporting}
+              disabled={loading}
+              onClick={() => void handleExport()}
+            >
+              导出 Excel
+            </Button>
           </Space>
         </div>
         {loading && !hasLoaded ? (

+ 2 - 1
requirements.txt

@@ -4,4 +4,5 @@ apscheduler==3.10.4
 pydantic-settings==2.6.1
 sqlalchemy==2.0.36
 pymysql==1.1.1
-pyodps==0.12.6
+pyodps==0.12.6
+openpyxl==3.1.5