فهرست منبع

增加需求查询

xueyiming 2 هفته پیش
والد
کامیت
68ed5e6f0d
5فایلهای تغییر یافته به همراه1396 افزوده شده و 119 حذف شده
  1. 106 0
      app/api/routes.py
  2. 5 0
      app/services/demand_pool_service.py
  3. 423 0
      app/services/element_search_service.py
  4. 562 55
      frontend/src/App.tsx
  5. 300 64
      frontend/src/styles.css

+ 106 - 0
app/api/routes.py

@@ -6,6 +6,11 @@ from app.services.demand_pool_service import (
     query_demand_pool_records,
     query_strategy_options,
 )
+from app.services.element_search_service import (
+    query_monthly_element_demands,
+    query_same_period_last_year_element_demands,
+    query_same_period_last_year_lunar_element_demands,
+)
 
 router = APIRouter()
 
@@ -30,6 +35,10 @@ async def query_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="排序字段"),
@@ -41,6 +50,7 @@ async def query_demand_pool(
         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,
@@ -50,6 +60,102 @@ async def query_demand_pool(
     )
 
 
+@router.get("/element-demands/solar-calendar")
+async def get_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)",
+    ),
+) -> dict[str, object]:
+    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,
+    )
+    return {"items": items}
+
+
+@router.get("/element-demands/lunar-calendar")
+async def get_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)",
+    ),
+) -> dict[str, object]:
+    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,
+    )
+    return {"items": items}
+
+
+@router.get("/element-demands/monthly")
+async def get_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="元素在回溯窗口内满足条件的月份数下限",
+    ),
+) -> dict[str, object]:
+    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,
+    )
+    return {"items": items}
+
+
 @router.get("/demand-pool/strategies")
 async def get_demand_pool_strategies(
     start_dt: str | None = Query(default=None, description="开始日期: yyyymmdd 或 yyyy-mm-dd"),

+ 5 - 0
app/services/demand_pool_service.py

@@ -24,6 +24,7 @@ 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,
@@ -55,6 +56,10 @@ def query_demand_pool_records(
     if normalized_end_dt:
         where_parts.append("dt <= :end_dt")
         params["end_dt"] = normalized_end_dt
+    demand_name_needle = (demand_name or "").strip()
+    if demand_name_needle:
+        where_parts.append("LOCATE(:demand_name_filter, demand_name) > 0")
+        params["demand_name_filter"] = demand_name_needle
     if min_weight is not None:
         where_parts.append("weight >= :min_weight")
         params["min_weight"] = min_weight

+ 423 - 0
app/services/element_search_service.py

@@ -0,0 +1,423 @@
+"""实质元素需求检索:通过 ODPS(MaxCompute)执行 Hive SQL。"""
+
+import json
+import re
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+from zoneinfo import ZoneInfo
+
+from app.odps.client import get_odps_client
+
+_DATE_PARTITION_RE = re.compile(r"^\d{8}$")
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+
+
+def _today_shanghai() -> date:
+    return datetime.now(SHANGHAI_TZ).date()
+
+
+def _same_day_last_year(value: date) -> date:
+    try:
+        return value.replace(year=value.year - 1)
+    except ValueError:
+        return date(value.year - 1, 2, 28)
+
+
+def _partition_yyyymmdd(value: date) -> str:
+    return value.strftime("%Y%m%d")
+
+
+def _validate_partition_dt(label: str, value: str) -> str:
+    if not _DATE_PARTITION_RE.match(value):
+        raise ValueError(f"{label} 须为 YYYYMMDD 分区格式,当前为 {value!r}")
+    return value
+
+
+def _serialize_video_list(value: object) -> str | None:
+    if value is None:
+        return None
+    if isinstance(value, list):
+        return json.dumps(value, ensure_ascii=False)
+    return str(value)
+
+
+def _normalize_scalar(value: object) -> object:
+    if isinstance(value, Decimal):
+        return float(value)
+    return value
+
+
+def query_same_period_last_year_element_demands(
+    *,
+    period_days: int,
+    view_pv_count: int,
+    min_contribution_score: float | int,
+    rov_avg: float | int,
+) -> list[dict[str, object]]:
+    """去年同期阳历策略下的实质元素需求聚合(ROV 等均值过滤)。
+
+    分区 dt 范围(YYYYMMDD)由「上海时区下的去年今日」与时间段推算:
+    - start_date:去年的今天(与今年同日历月日;闰年 2 月 29 日则退成去年 2 月 28 日)
+    - end_date:从 start_date 起连续 period_days 个日历日的最后一天(含起始日)
+
+    demand_id 中的 bizdate 固定为上海时区当天日期(YYYYMMDD),不作为入参。
+
+    其余参数:
+    - period_days:区间天数(含起始日),须 >= 0;为 0 仅含 start_date 当日;为 7 表示含 start_date 在内共 7 天分区
+    - view_pv_count:`当日分发曝光pv` 下限
+    - min_contribution_score:`贡献分` 下限
+    - rov_avg:分组后平均 ROV 下限
+    """
+    if period_days < 0:
+        raise ValueError("period_days 不能为负")
+
+    anchor_last_year = _same_day_last_year(_today_shanghai())
+    start_dt = anchor_last_year
+    end_dt = anchor_last_year + timedelta(days=max(0, period_days - 1))
+
+    start_date = _validate_partition_dt("start_date", _partition_yyyymmdd(start_dt))
+    end_date = _validate_partition_dt("end_date", _partition_yyyymmdd(end_dt))
+    bizdate = _partition_yyyymmdd(_today_shanghai())
+    if view_pv_count < 0:
+        raise ValueError("view_pv_count 不能为负")
+
+    sql = f"""
+WITH cleaned_video_metrics AS
+(
+    SELECT  CAST(视频id AS STRING) AS vid
+            ,rov_t0
+    FROM    loghubods.video_dimension_detail_add_column
+    WHERE   dt >= '{start_date}'
+    AND     dt <= '{end_date}'
+    AND     COALESCE(`当日分发曝光pv`,0) >= {int(view_pv_count)}
+)
+,video_avg_metrics AS
+(
+    SELECT  vid
+            ,AVG(CASE WHEN rov_t0 = 0 THEN NULL ELSE rov_t0 END) AS vid_avg_rov
+    FROM    cleaned_video_metrics
+    GROUP BY vid
+)
+,tag_vid_dedup AS
+(
+    SELECT  DISTINCT CAST(vid AS STRING) AS vid
+            ,原始元素
+            ,原始元素描述
+            ,点类型
+            ,元素维度
+            ,短语
+            ,选题
+            ,`extend`
+    FROM    loghubods.dwd_topic_decode_result_detail_di
+    WHERE   dt = MAX_PT('loghubods.dwd_topic_decode_result_detail_di')
+    AND     TRIM(原始元素) <> ''
+    AND     原始元素 IS NOT NULL
+    AND     元素维度 = '实质'
+    AND     贡献分 >= {float(min_contribution_score)}
+)
+SELECT  '去年同期阳历' AS strategy
+        ,md5(CONCAT('去年同期阳历',t1.原始元素,'{bizdate}')) AS demand_id
+        ,t1.原始元素 AS demand_name
+        ,COALESCE(ROUND(AVG(t2.vid_avg_rov),6),0) AS weight
+        ,COUNT(DISTINCT t1.vid) AS video_count
+        ,COLLECT_SET(t1.vid) AS video_list
+        ,'{{}}' AS ext_info
+FROM    tag_vid_dedup t1
+LEFT JOIN video_avg_metrics t2
+ON      t1.vid = t2.vid
+GROUP BY t1.原始元素
+HAVING  COALESCE(ROUND(AVG(t2.vid_avg_rov),6),0) >= {float(rov_avg)}
+ORDER BY weight DESC
+"""
+
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+
+    rows: list[dict[str, object]] = []
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            rows.append(
+                {
+                    "strategy": record["strategy"],
+                    "demand_id": record["demand_id"],
+                    "demand_name": record["demand_name"],
+                    "weight": _normalize_scalar(record["weight"]),
+                    "video_count": record["video_count"],
+                    "video_list": _serialize_video_list(record["video_list"]),
+                    "ext_info": record["ext_info"],
+                }
+            )
+    return rows
+
+
+def query_same_period_last_year_lunar_element_demands(
+    *,
+    period_days: int,
+    view_pv_count: int,
+    min_contribution_score: float | int,
+    rov_avg: float | int,
+) -> list[dict[str, object]]:
+    """去年同期阴历策略下的实质元素需求聚合(ROV 等均值过滤)。
+
+    日期区间由 SQL 内 lunar_to_solar / lunar_add / solar_to_lunar 推算;
+    end_date 为 start_date 起连续 period_days 天的最后一天(含首日),与阳历接口语义一致。
+    bizdate 为上海时区当天 YYYYMMDD。
+    """
+    if period_days < 0:
+        raise ValueError("period_days 不能为负")
+    if view_pv_count < 0:
+        raise ValueError("view_pv_count 不能为负")
+
+    bizdate = _validate_partition_dt("bizdate", _partition_yyyymmdd(_today_shanghai()))
+    days_after = max(0, period_days - 1)
+
+    sql = f"""
+SET odps.sql.type.system.odps2 = true
+;
+SET odps.sql.decimal.odps2 = true
+;
+
+WITH base_date AS
+(
+    SELECT  lunar_to_solar(lunar_add(loghubods.solar_to_lunar('{bizdate}','yyyyMMdd'),-1,'Y')) AS start_date
+)
+,date_params AS
+(
+    SELECT  start_date
+            ,DATE_FORMAT(CAST(DATE_ADD(TO_DATE(start_date,'yyyyMMdd'),{int(days_after)}) AS TIMESTAMP),'yyyyMMdd') AS end_date
+    FROM    base_date
+)
+,cleaned_video_metrics AS
+(
+    SELECT  CAST(d.视频id AS STRING) AS vid
+            ,d.rov_t0
+    FROM    loghubods.video_dimension_detail_add_column d
+    WHERE   d.dt >= (
+                SELECT  start_date
+                FROM    date_params
+            )
+    AND     d.dt <= (
+                SELECT  end_date
+                FROM    date_params
+            )
+    AND     COALESCE(d.`当日分发曝光pv`,0) >= {int(view_pv_count)}
+)
+,video_avg_metrics AS
+(
+    SELECT  vid
+            ,AVG(CASE WHEN rov_t0 = 0 THEN NULL ELSE rov_t0 END) AS vid_avg_rov
+    FROM    cleaned_video_metrics
+    GROUP BY vid
+)
+,tag_vid_dedup AS
+(
+    SELECT  DISTINCT CAST(vid AS STRING) AS vid
+            ,原始元素
+            ,原始元素描述
+            ,点类型
+            ,元素维度
+            ,短语
+            ,选题
+            ,`extend`
+    FROM    loghubods.dwd_topic_decode_result_detail_di
+    WHERE   dt = MAX_PT('loghubods.dwd_topic_decode_result_detail_di')
+    AND     TRIM(原始元素) <> ''
+    AND     原始元素 IS NOT NULL
+    AND     元素维度 = '实质'
+    AND     贡献分 >= {float(min_contribution_score)}
+)
+SELECT  '去年同期阴历' AS strategy
+        ,md5(CONCAT('去年同期阴历',t1.原始元素,'{bizdate}')) AS demand_id
+        ,t1.原始元素 AS demand_name
+        ,CAST(COALESCE(ROUND(AVG(t2.vid_avg_rov), 6), 0) AS DECIMAL(20,6)) AS weight
+        ,COUNT(DISTINCT t1.vid) AS video_count
+        ,COLLECT_SET(t1.vid) AS video_list
+        ,'{{}}' AS ext_info
+FROM    tag_vid_dedup t1
+LEFT JOIN video_avg_metrics t2
+ON      t1.vid = t2.vid
+GROUP BY t1.原始元素
+HAVING  COALESCE(ROUND(AVG(t2.vid_avg_rov),6),0) >= {float(rov_avg)}
+ORDER BY weight DESC
+"""
+
+    odps_client = get_odps_client()
+    # 脚本模式:SET 多条 + SELECT;DECIMAL(p,s) 需 odps.sql.decimal.odps2
+    instance = odps_client.execute_sql(
+        sql,
+        hints={
+            "odps.sql.submit.mode": "script",
+            "odps.sql.decimal.odps2": "true",
+        },
+    )
+
+    rows: list[dict[str, object]] = []
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            rows.append(
+                {
+                    "strategy": record["strategy"],
+                    "demand_id": record["demand_id"],
+                    "demand_name": record["demand_name"],
+                    "weight": _normalize_scalar(record["weight"]),
+                    "video_count": record["video_count"],
+                    "video_list": _serialize_video_list(record["video_list"]),
+                    "ext_info": record["ext_info"],
+                }
+            )
+    return rows
+
+
+def query_monthly_element_demands(
+    *,
+    view_pv_count: int,
+    month_total_pv_threshold: float | int,
+    min_contribution_score: float | int,
+    rov_avg: float | int,
+    min_frequency: int,
+) -> list[dict[str, object]]:
+    """逐月策略:按 bizdate 所在自然月回溯 12 个月窗口,聚合实质元素需求。"""
+    if view_pv_count < 0:
+        raise ValueError("view_pv_count 不能为负")
+    if float(month_total_pv_threshold) < 0:
+        raise ValueError("month_total_pv_threshold 不能为负")
+    if min_frequency < 0:
+        raise ValueError("min_frequency 不能为负")
+
+    bizdate = _validate_partition_dt("bizdate", _partition_yyyymmdd(_today_shanghai()))
+
+    sql = f"""
+WITH biz_month AS (
+    SELECT
+        TO_DATE(CONCAT(SUBSTR('{bizdate}', 1, 4), '-', SUBSTR('{bizdate}', 5, 2), '-01')) AS biz_m1
+),
+month_window AS (
+    SELECT
+        CONCAT(
+            SUBSTR(CAST(ADD_MONTHS(biz_m1, -12) AS STRING), 1, 4),
+            SUBSTR(CAST(ADD_MONTHS(biz_m1, -12) AS STRING), 6, 2)
+        ) AS start_ym,
+        CONCAT(
+            SUBSTR(CAST(ADD_MONTHS(biz_m1, -1) AS STRING), 1, 4),
+            SUBSTR(CAST(ADD_MONTHS(biz_m1, -1) AS STRING), 6, 2)
+        ) AS end_ym
+    FROM biz_month
+),
+cleaned_video_metrics AS (
+    SELECT
+        CAST(视频id AS STRING) AS vid,
+        SUBSTR(CAST(dt AS STRING), 1, 6) AS ym,
+        rov_t0,
+        COALESCE(`当日分发曝光pv`, 0) AS day_dist_pv
+    FROM loghubods.video_dimension_detail_add_column
+    WHERE SUBSTR(CAST(dt AS STRING), 1, 6) >= (SELECT start_ym FROM month_window)
+      AND SUBSTR(CAST(dt AS STRING), 1, 6) <= (SELECT end_ym FROM month_window)
+      AND COALESCE(`当日分发曝光pv`, 0) >= {int(view_pv_count)}
+),
+video_monthly_avg_metrics AS (
+    SELECT
+        ym,
+        vid,
+        AVG(CASE WHEN rov_t0 = 0 THEN NULL ELSE rov_t0 END) AS vid_avg_rov,
+        SUM(day_dist_pv) AS month_total_pv
+    FROM cleaned_video_metrics
+    GROUP BY ym, vid
+    HAVING SUM(day_dist_pv) > {float(month_total_pv_threshold)}
+),
+tag_vid_dedup AS (
+    SELECT DISTINCT
+        CAST(vid AS STRING) AS vid,
+        原始元素
+    FROM loghubods.dwd_topic_decode_result_detail_di
+    WHERE dt = MAX_PT('loghubods.dwd_topic_decode_result_detail_di')
+      AND 元素维度 = '实质'
+      AND 贡献分 >= {float(min_contribution_score)}
+),
+element_monthly_metrics AS (
+    SELECT
+        t1.原始元素,
+        t2.ym,
+        COALESCE(ROUND(AVG(t2.vid_avg_rov), 6), 0) AS month_avg_rov
+    FROM tag_vid_dedup t1
+    JOIN video_monthly_avg_metrics t2
+      ON t1.vid = t2.vid
+    GROUP BY t1.原始元素, t2.ym
+    HAVING COALESCE(ROUND(AVG(t2.vid_avg_rov), 6), 0) >= {float(rov_avg)}
+),
+element_total_rov AS (
+    SELECT
+        原始元素,
+        ROUND(SUM(month_avg_rov), 6) AS avg_rov
+    FROM element_monthly_metrics
+    GROUP BY 原始元素
+),
+element_vid_dedup AS (
+    SELECT DISTINCT
+        em.原始元素,
+        vm.vid
+    FROM element_monthly_metrics em
+    JOIN tag_vid_dedup tv
+      ON em.原始元素 = tv.原始元素
+    JOIN video_monthly_avg_metrics vm
+      ON tv.vid = vm.vid
+     AND em.ym = vm.ym
+),
+element_vid_stats AS (
+    SELECT
+        原始元素,
+        COUNT(DISTINCT vid) AS vid_count,
+        COLLECT_SET(vid) AS vid_list
+    FROM element_vid_dedup
+    GROUP BY 原始元素
+),
+element_freq AS (
+    SELECT
+        原始元素,
+        COUNT(1) AS 频次
+    FROM element_monthly_metrics
+    GROUP BY 原始元素
+)
+SELECT
+    '逐月' AS strategy,
+    md5(CONCAT('逐月', r.原始元素, '{bizdate}')) AS demand_id,
+    r.原始元素 AS demand_name,
+    r.avg_rov AS weight,
+    COALESCE(v.vid_count, 0) AS video_count,
+    v.vid_list AS video_list,
+    '{{}}' AS ext_info
+FROM element_total_rov r
+LEFT JOIN element_vid_stats v
+  ON r.原始元素 = v.原始元素
+LEFT JOIN element_freq f
+  ON r.原始元素 = f.原始元素
+WHERE r.原始元素 NOT IN (
+    '元旦','腊八节','小年','除夕','春节','正月初一','正月初二','正月初三','正月初四','正月初五',
+    '情人节','元宵节','龙抬头','妇女节','植树节','劳动节','母亲节','儿童节','端午节','父亲节',
+    '建党节','建军节','七夕节','中元节','中秋节','国庆节','重阳节','感恩节','公祭日','平安夜',
+    '圣诞节','小寒','大寒','立春','雨水','惊蛰','春分','清明','谷雨','立夏','小满','芒种',
+    '夏至','小暑','大暑','立秋','处暑','白露','秋分','寒露','霜降','立冬','小雪','大雪','冬至',
+    '早上好','中午好','下午好','晚上好','晚安',
+    '祝福','祝愿','祝你','祝贺','祝大家','祝您','祝好运','祝群主','祝朋友'
+)
+AND COALESCE(f.频次, 0) >= {int(min_frequency)}
+ORDER BY r.avg_rov DESC
+"""
+
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+
+    rows: list[dict[str, object]] = []
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            rows.append(
+                {
+                    "strategy": record["strategy"],
+                    "demand_id": record["demand_id"],
+                    "demand_name": record["demand_name"],
+                    "weight": _normalize_scalar(record["weight"]),
+                    "video_count": record["video_count"],
+                    "video_list": _serialize_video_list(record["video_list"]),
+                    "ext_info": record["ext_info"],
+                }
+            )
+    return rows

+ 562 - 55
frontend/src/App.tsx

@@ -1,16 +1,19 @@
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import {
   Alert,
   Button,
-  Card,
   DatePicker,
   Form,
+  Input,
   InputNumber,
+  List,
+  Modal,
   Pagination,
   Select,
   Skeleton,
   Space,
   Table,
+  Tabs,
   Tag,
   Typography,
 } from "antd";
@@ -44,6 +47,36 @@ type StrategyResponse = {
   items: StrategyOption[];
 };
 
+type ElementDemandItem = {
+  strategy: string | null;
+  demand_id: string | null;
+  demand_name: string | null;
+  weight: number | null;
+  video_count: number | null;
+  video_list: string | null;
+  ext_info: string | null;
+};
+
+type ElementDemandResponse = {
+  items: ElementDemandItem[];
+};
+
+function parseVideoIdsFromList(raw: string | null): string[] {
+  const t = (raw ?? "").trim();
+  if (!t) {
+    return [];
+  }
+  try {
+    const parsed: unknown = JSON.parse(t);
+    if (Array.isArray(parsed)) {
+      return parsed.map((item) => String(item));
+    }
+  } catch {
+    /* 非 JSON 数组则走下方原始文本展示 */
+  }
+  return [];
+}
+
 const API_BASE_URL =
   import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
 
@@ -54,7 +87,7 @@ const getResolvedApiBaseUrl = () => {
   return new URL(API_BASE_URL, window.location.origin).toString();
 };
 
-function App() {
+function DemandPoolPanel() {
   const [strategies, setStrategies] = useState<string[]>([]);
   const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
     const today = dayjs();
@@ -68,6 +101,8 @@ function App() {
   const [maxWeightInput, setMaxWeightInput] = useState<number | null>(null);
   const [appliedMinWeight, setAppliedMinWeight] = useState<number | null>(null);
   const [appliedMaxWeight, setAppliedMaxWeight] = useState<number | null>(null);
+  const [demandNameInput, setDemandNameInput] = useState("");
+  const [appliedDemandName, setAppliedDemandName] = useState("");
   const [sortBy, setSortBy] = useState("weight");
   const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
   const [loading, setLoading] = useState(false);
@@ -93,6 +128,7 @@ function App() {
     endDate,
     appliedMinWeight,
     appliedMaxWeight,
+    appliedDemandName,
     sortBy,
     sortOrder,
     currentPage,
@@ -121,6 +157,10 @@ function App() {
     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);
     url.searchParams.set("page", String(page));
@@ -221,6 +261,7 @@ function App() {
     }
     setAppliedMinWeight(minWeightInput);
     setAppliedMaxWeight(maxWeightInput);
+    setAppliedDemandName(demandNameInput.trim());
     setCurrentPage(1);
     setRefreshTick((value) => value + 1);
   };
@@ -240,6 +281,8 @@ function App() {
     setMaxWeightInput(null);
     setAppliedMinWeight(null);
     setAppliedMaxWeight(null);
+    setDemandNameInput("");
+    setAppliedDemandName("");
     setSortBy("weight");
     setSortOrder("desc");
     setCurrentPage(1);
@@ -298,19 +341,14 @@ function App() {
   );
 
   return (
-    <div className="page">
-      <div className="hero">
-        <Typography.Title level={2} className="hero-title">
-          需求池数据看板
-        </Typography.Title>
-        <div className="hero-subtitle">
-          <Tag color="blue">数据检索</Tag>
-          <Tag color="cyan">策略筛选</Tag>
-          <Tag color="geekblue">日期范围分析</Tag>
-        </div>
-      </div>
-
-      <Card className="filter-card">
+    <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={() => void handleSubmit()} className="filter-form">
           <div className="filter-row">
             <Form.Item label="策略名" className="strategy-item">
@@ -358,6 +396,14 @@ function App() {
               </Space>
             </Form.Item>
 
+            <Form.Item label="需求名称">
+              <Input
+                allowClear
+                value={demandNameInput}
+                onChange={(e) => setDemandNameInput(e.target.value)}
+              />
+            </Form.Item>
+
             <Form.Item label=" ">
               <Button
                 type="primary"
@@ -371,7 +417,7 @@ function App() {
 
             <Form.Item label=" ">
               <Space>
-                <Button type="dashed" onClick={resetFilters}>
+                <Button type="default" onClick={resetFilters}>
                   重置
                 </Button>
               </Space>
@@ -398,50 +444,53 @@ function App() {
         {error ? (
           <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
         ) : null}
-      </Card>
+      </section>
 
-      <Card className="table-card">
-        <div className="table-title-row">
-          <Typography.Text strong>需求明细</Typography.Text>
+      <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">已选策略 {strategies.length}</span>
+            <span className="meta-chip">共 {data.total} 条</span>
+            <span className="meta-chip">
+              第 {currentPage} / {totalPages} 页
+            </span>
+          </Space>
         </div>
         {loading && !hasLoaded ? (
           <Skeleton active paragraph={{ rows: 10 }} />
         ) : (
-          <Table
-            rowKey="id"
-            loading={loading}
-            columns={columns}
-            dataSource={data.items}
-            pagination={false}
-            scroll={{ x: 920 }}
-            rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
-            onChange={(_, __, sorter) => {
-              if (Array.isArray(sorter)) {
-                return;
-              }
-              const nextField = typeof sorter.field === "string" ? sorter.field : null;
-              const nextOrder = sorter.order;
-              if (!nextField || !nextOrder) {
-                setSortBy("weight");
-                setSortOrder("desc");
+          <div className="table-wrap">
+            <Table
+              rowKey="id"
+              loading={loading}
+              columns={columns}
+              dataSource={data.items}
+              pagination={false}
+              scroll={{ x: 920 }}
+              rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
+              onChange={(_, __, sorter) => {
+                if (Array.isArray(sorter)) {
+                  return;
+                }
+                const nextField = typeof sorter.field === "string" ? sorter.field : null;
+                const nextOrder = sorter.order;
+                if (!nextField || !nextOrder) {
+                  setSortBy("weight");
+                  setSortOrder("desc");
+                  setCurrentPage(1);
+                  return;
+                }
+                setSortBy(nextField);
+                setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
                 setCurrentPage(1);
-                return;
-              }
-              setSortBy(nextField);
-              setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
-              setCurrentPage(1);
-            }}
-          />
+              }}
+            />
+          </div>
         )}
-        <div className="footer-bar">
-          <Space size={10} wrap>
-            <span className="pill">已选策略:{strategies.length}</span>
-            <span className="pill">总条数:{data.total}</span>
-            <span className="pill">
-              页码:{currentPage} / {totalPages}
-            </span>
-          </Space>
-
+        <div className="panel-footer">
           <Pagination
             current={currentPage}
             total={data.total}
@@ -463,7 +512,465 @@ function App() {
             }}
           />
         </div>
-      </Card>
+      </section>
+    </div>
+  );
+}
+
+function ElementDemandQueryPanel({
+  active,
+  apiPath,
+  periodDaysLabel,
+  tableDetailTitle,
+  mode = "period",
+}: {
+  active: boolean;
+  apiPath: string;
+  periodDaysLabel: string;
+  tableDetailTitle: string;
+  /** period:阳历/阴历同期;monthly:逐月回溯窗口 */
+  mode?: "period" | "monthly";
+}) {
+  const [periodDays, setPeriodDays] = useState(7);
+  const [monthTotalPvThreshold, setMonthTotalPvThreshold] = useState(20000);
+  const [minFrequency, setMinFrequency] = useState(4);
+  const [viewPvCount, setViewPvCount] = useState(2000);
+  const [minContributionScore, setMinContributionScore] = useState(0.8);
+  const [rovAvg, setRovAvg] = useState(0.04);
+
+  const [items, setItems] = useState<ElementDemandItem[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [hasLoaded, setHasLoaded] = useState(false);
+  const [error, setError] = useState("");
+
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+
+  const [videoModalOpen, setVideoModalOpen] = useState(false);
+  const [videoModalTitleName, setVideoModalTitleName] = useState("");
+  const [videoModalIds, setVideoModalIds] = useState<string[]>([]);
+  const [videoModalRaw, setVideoModalRaw] = useState("");
+
+  const buildQueryUrl = useCallback(() => {
+    const resolvedBase = getResolvedApiBaseUrl();
+    const baseWithSlash = resolvedBase.endsWith("/")
+      ? resolvedBase
+      : `${resolvedBase}/`;
+    const url = new URL(apiPath, 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 fetchAll = useCallback(async () => {
+    setLoading(true);
+    setError("");
+    try {
+      const response = await fetch(buildQueryUrl(), {
+        method: "GET",
+        headers: { Accept: "application/json" },
+      });
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+      }
+      const payload = (await response.json()) as ElementDemandResponse;
+      setItems(payload.items ?? []);
+      setPage(1);
+    } catch (queryError) {
+      setError(
+        queryError instanceof Error ? queryError.message : "查询失败,请重试"
+      );
+      setItems([]);
+    } finally {
+      setLoading(false);
+      setHasLoaded(true);
+    }
+  }, [buildQueryUrl]);
+
+  /** 仅在本会话内首次进入该 Tab 时自动请求一次;数据留在 state 中,切走再回来不重复请求 */
+  const autoFetchedOnceRef = useRef(false);
+  useEffect(() => {
+    if (!active) {
+      return;
+    }
+    if (autoFetchedOnceRef.current) {
+      return;
+    }
+    autoFetchedOnceRef.current = true;
+    void fetchAll();
+  }, [active, fetchAll]);
+
+  const handleSubmit = () => {
+    void fetchAll();
+  };
+
+  const resetDefaults = () => {
+    setPeriodDays(7);
+    setMonthTotalPvThreshold(20000);
+    setMinFrequency(4);
+    setViewPvCount(2000);
+    setMinContributionScore(0.8);
+    setRovAvg(0.04);
+    setPage(1);
+  };
+
+  const total = items.length;
+  const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+  const pagedItems = useMemo(() => {
+    const start = (page - 1) * pageSize;
+    return items.slice(start, start + pageSize);
+  }, [items, page, pageSize]);
+
+  const columns: ColumnsType<ElementDemandItem> = useMemo(
+    () => [
+      {
+        title: "策略",
+        dataIndex: "strategy",
+        width: 120,
+        render: (v) => v ?? "-",
+      },
+      {
+        title: "需求名称",
+        dataIndex: "demand_name",
+        ellipsis: true,
+        render: (v) => v ?? "-",
+      },
+      {
+        title: "权重",
+        dataIndex: "weight",
+        width: 110,
+        render: (v) => (v === null || v === undefined ? "-" : String(v)),
+      },
+      {
+        title: "视频数",
+        dataIndex: "video_count",
+        width: 100,
+        render: (v) => v ?? "-",
+      },
+      {
+        title: "视频列表",
+        dataIndex: "video_list",
+        width: 220,
+        render: (_, record) => {
+          const raw = record.video_list ?? "";
+          const trimmed = raw.trim();
+          if (!trimmed) {
+            return "-";
+          }
+          const ids = parseVideoIdsFromList(raw);
+          const label =
+            ids.length > 0
+              ? `共 ${ids.length} 条,点击查看`
+              : trimmed.length > 36
+                ? `${trimmed.slice(0, 36)}…`
+                : trimmed;
+          return (
+            <Typography.Link
+              onClick={() => {
+                setVideoModalTitleName(record.demand_name ?? "");
+                setVideoModalIds(ids);
+                setVideoModalRaw(raw);
+                setVideoModalOpen(true);
+              }}
+            >
+              {label}
+            </Typography.Link>
+          );
+        },
+      },
+    ],
+    []
+  );
+
+  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={() => void handleSubmit()} className="filter-form">
+          <div className="filter-row second-row element-demand-filter-row">
+            {mode === "period" ? (
+              <Form.Item label={periodDaysLabel}>
+                <InputNumber
+                  min={0}
+                  precision={0}
+                  value={periodDays}
+                  onChange={(v) => setPeriodDays(v ?? 7)}
+                />
+              </Form.Item>
+            ) : null}
+            <Form.Item label="当日分发曝光PV限制">
+              <InputNumber
+                min={0}
+                precision={0}
+                value={viewPvCount}
+                onChange={(v) => setViewPvCount(v ?? 0)}
+              />
+            </Form.Item>
+            {mode === "monthly" ? (
+              <Form.Item label="月累计分发曝光PV阈值">
+                <InputNumber
+                  min={0}
+                  precision={0}
+                  value={monthTotalPvThreshold}
+                  onChange={(v) => setMonthTotalPvThreshold(v ?? 0)}
+                />
+              </Form.Item>
+            ) : null}
+            <Form.Item label="贡献分限制">
+              <InputNumber
+                min={0}
+                step={0.01}
+                value={minContributionScore}
+                onChange={(v) => setMinContributionScore(v ?? 0)}
+              />
+            </Form.Item>
+            <Form.Item label="累加平均ROV限制">
+              <InputNumber
+                min={0}
+                step={0.001}
+                value={rovAvg}
+                onChange={(v) => setRovAvg(v ?? 0)}
+              />
+            </Form.Item>
+            {mode === "monthly" ? (
+              <Form.Item label="元素频次限制(有效月份数)">
+                <InputNumber
+                  min={0}
+                  precision={0}
+                  value={minFrequency}
+                  onChange={(v) => setMinFrequency(v ?? 0)}
+                />
+              </Form.Item>
+            ) : null}
+            <Form.Item label=" ">
+              <Button type="primary" htmlType="submit" loading={loading}>
+                查询
+              </Button>
+            </Form.Item>
+            <Form.Item label=" ">
+              <Button type="default" onClick={resetDefaults}>
+                恢复默认
+              </Button>
+            </Form.Item>
+          </div>
+        </Form>
+        {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">
+            {tableDetailTitle}
+          </Typography.Title>
+          <Space size={8} wrap className="table-toolbar-meta">
+            <span className="meta-chip">本地 {total} 条</span>
+            <span className="meta-chip">
+              第 {page} / {totalPages} 页
+            </span>
+          </Space>
+        </div>
+        {loading && !hasLoaded ? (
+          <Skeleton active paragraph={{ rows: 10 }} />
+        ) : (
+          <div className="table-wrap">
+            <Table
+              rowKey={(record) =>
+                String(record.demand_id ?? "").trim() ||
+                `${record.strategy ?? ""}|${record.demand_name ?? ""}`
+              }
+              loading={loading}
+              columns={columns}
+              dataSource={pagedItems}
+              pagination={false}
+              scroll={{ x: 880 }}
+              rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
+            />
+          </div>
+        )}
+        <Modal
+          title={`视频列表${videoModalTitleName ? ` — ${videoModalTitleName}` : ""}`}
+          open={videoModalOpen}
+          onCancel={() => setVideoModalOpen(false)}
+          footer={
+            <Button type="primary" onClick={() => setVideoModalOpen(false)}>
+              关闭
+            </Button>
+          }
+          width={720}
+          destroyOnHidden
+        >
+          {videoModalIds.length > 0 ? (
+            <List
+              size="small"
+              bordered
+              dataSource={videoModalIds}
+              style={{ maxHeight: 480, overflow: "auto" }}
+              renderItem={(vid, idx) => (
+                <List.Item>
+                  <Typography.Text type="secondary" style={{ marginRight: 8 }}>
+                    {idx + 1}.
+                  </Typography.Text>
+                  <Typography.Text copyable>{vid}</Typography.Text>
+                </List.Item>
+              )}
+            />
+          ) : (
+            <Typography.Paragraph
+              copyable={{ text: videoModalRaw }}
+              style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
+            >
+              {videoModalRaw || "(空)"}
+            </Typography.Paragraph>
+          )}
+        </Modal>
+        <div className="panel-footer">
+          <Pagination
+            current={page}
+            total={total}
+            pageSize={pageSize}
+            showSizeChanger
+            pageSizeOptions={["10", "20", "50", "100"]}
+            showQuickJumper
+            showTotal={(t) => `共 ${t} 条`}
+            onChange={(nextPage, size) => {
+              const nextSize = size ?? pageSize;
+              if (nextSize !== pageSize) {
+                setPageSize(nextSize);
+                setPage(1);
+                return;
+              }
+              setPage(nextPage);
+            }}
+            onShowSizeChange={(_, size) => {
+              setPageSize(size);
+              setPage(1);
+            }}
+          />
+        </div>
+      </section>
+    </div>
+  );
+}
+
+function SolarCalendarPanel({ active }: { active: boolean }) {
+  return (
+    <ElementDemandQueryPanel
+      active={active}
+      apiPath="element-demands/solar-calendar"
+      periodDaysLabel="区间天数(含去年阳历今日)"
+      tableDetailTitle="去年同期阳历需求明细"
+    />
+  );
+}
+
+function LunarCalendarPanel({ active }: { active: boolean }) {
+  return (
+    <ElementDemandQueryPanel
+      active={active}
+      apiPath="element-demands/lunar-calendar"
+      periodDaysLabel="区间天数(含去年阴历今日)"
+      tableDetailTitle="去年同期阴历需求明细"
+    />
+  );
+}
+
+function MonthlyDemandPanel({ active }: { active: boolean }) {
+  return (
+    <ElementDemandQueryPanel
+      active={active}
+      apiPath="element-demands/monthly"
+      periodDaysLabel=""
+      tableDetailTitle="逐月需求明细"
+      mode="monthly"
+    />
+  );
+}
+
+function App() {
+  const [activeTab, setActiveTab] = useState("demand-pool");
+
+  const tabItems = useMemo(
+    () => [
+      {
+        key: "demand-pool",
+        label: "需求池",
+        children: <DemandPoolPanel />,
+      },
+      {
+        key: "solar-calendar",
+        label: "去年同期阳历需求查询",
+        children: (
+          <SolarCalendarPanel active={activeTab === "solar-calendar"} />
+        ),
+      },
+      {
+        key: "lunar-calendar",
+        label: "去年同期阴历需求查询",
+        children: (
+          <LunarCalendarPanel active={activeTab === "lunar-calendar"} />
+        ),
+      },
+      {
+        key: "monthly-demand",
+        label: "逐月需求查询",
+        children: (
+          <MonthlyDemandPanel active={activeTab === "monthly-demand"} />
+        ),
+      },
+    ],
+    [activeTab],
+  );
+
+  return (
+    <div className="page">
+      <div className="hero">
+        <Typography.Title level={2} className="hero-title">
+          需求池数据看板
+        </Typography.Title>
+        <div className="hero-subtitle">
+          <Tag color="blue">数据检索</Tag>
+          <Tag color="cyan">策略筛选</Tag>
+          <Tag color="geekblue">日期范围分析</Tag>
+          <Tag color="purple">需求筛选</Tag>
+        </div>
+      </div>
+
+      <div className="dashboard-shell">
+        <Tabs
+          className="main-tabs demand-nav-tabs"
+          activeKey={activeTab}
+          onChange={setActiveTab}
+          tabBarGutter={16}
+          items={tabItems}
+        />
+      </div>
     </div>
   );
 }

+ 300 - 64
frontend/src/styles.css

@@ -1,17 +1,20 @@
 :root {
   font-family: "PingFang SC", "Microsoft YaHei", Inter, sans-serif;
+  --surface-muted: #f8fafc;
+  --text-primary: #0f172a;
+  --accent: #2563eb;
 }
 
 body {
   margin: 0;
-  background: radial-gradient(circle at 10% 20%, #eef4ff 0%, #f5f7fb 45%, #f7f9fc 100%);
+  background: #f1f5f9;
 }
 
 .page {
   position: relative;
-  max-width: 1280px;
+  max-width: 1320px;
   margin: 0 auto;
-  padding: 24px 20px 36px;
+  padding: 28px 22px 40px;
 }
 
 .page::before,
@@ -19,30 +22,31 @@ body {
   content: "";
   position: absolute;
   border-radius: 999px;
-  filter: blur(40px);
+  filter: blur(48px);
   z-index: 0;
-  opacity: 0.45;
+  opacity: 0.35;
+  pointer-events: none;
 }
 
 .page::before {
-  width: 220px;
-  height: 220px;
-  right: 2%;
-  top: 10px;
-  background: #c4d7ff;
+  width: 280px;
+  height: 280px;
+  right: 4%;
+  top: -40px;
+  background: #93c5fd;
 }
 
 .page::after {
-  width: 180px;
-  height: 180px;
-  left: 0;
-  top: 210px;
-  background: #d4f0ff;
+  width: 200px;
+  height: 200px;
+  left: -2%;
+  top: 180px;
+  background: #7dd3fc;
 }
 
 .hero,
-.filter-card,
-.table-card {
+.dashboard-shell,
+.panel-sheet {
   position: relative;
   z-index: 1;
 }
@@ -52,7 +56,7 @@ body {
 }
 
 .hero-title {
-  margin-bottom: 4px !important;
+  margin-bottom: 6px !important;
   background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 45%, #0ea5e9 100%);
   -webkit-background-clip: text;
   background-clip: text;
@@ -61,9 +65,226 @@ body {
 
 .hero-subtitle {
   display: flex;
-  gap: 6px;
+  gap: 8px;
   flex-wrap: wrap;
+  margin-top: 4px;
+}
+
+/* 外层白盒:圆角+阴影只在这一层,彻底规避 Ant Tabs 主题自带的边框线 */
+.dashboard-shell {
+  margin-top: 10px;
+  border-radius: 14px;
+  overflow: hidden;
+  border: none !important;
+  outline: none !important;
+  background: #ffffff;
+  box-shadow:
+    0 22px 56px rgba(15, 23, 42, 0.048),
+    0 8px 22px rgba(15, 23, 42, 0.036);
+}
+
+.dashboard-shell .ant-tabs.main-tabs {
+  border: none !important;
+  outline: none !important;
+}
+
+.dashboard-shell .main-tabs.demand-nav-tabs.ant-tabs-top {
+  border: none !important;
+  outline: none !important;
+  box-shadow: none !important;
+  border-radius: 0 !important;
+  background: transparent !important;
+}
+
+.dashboard-shell .ant-tabs-nav,
+.dashboard-shell .ant-tabs-content-holder,
+.dashboard-shell .ant-tabs-content {
+  border: none !important;
+}
+
+.dashboard-shell .ant-tabs-content-holder {
+  border-top: none !important;
+}
+
+.main-tabs {
+  margin-top: 0;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-content-holder {
+  padding-top: 0;
+  background: #ffffff;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-content {
+  padding: 0 !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tabpane {
+  padding: 0 !important;
+}
+
+/* 顶部导航:嵌在外壳内的顶栏 + 渐变指示条 */
+.main-tabs.demand-nav-tabs .ant-tabs-nav {
+  margin: 0 !important;
+  padding: 4px 12px 0;
+  width: 100%;
+  box-sizing: border-box;
+  background: linear-gradient(180deg, #fbfcff 0%, #f4f8fc 88%, #eef3f8 100%);
+  border: none !important;
+  border-radius: 0 !important;
+  box-shadow: none !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-nav::before {
+  border-bottom: none !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-nav-wrap {
+  overflow: visible;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-nav-list {
+  display: flex !important;
+  width: auto;
+  gap: 2px;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tab {
+  flex: 0 0 auto !important;
+  margin: 0 !important;
+  padding: 18px 26px 14px !important;
+  font-size: 17px !important;
+  line-height: 1.3 !important;
+  font-weight: 600 !important;
+  letter-spacing: 0.02em;
+  color: #64748b !important;
+  border-radius: 12px 12px 0 0 !important;
+  border: none !important;
+  background: transparent !important;
+  transition:
+    color 0.2s ease,
+    background 0.2s ease;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tab:hover:not(.ant-tabs-tab-active) {
+  color: #2563eb !important;
+  background: rgba(37, 99, 235, 0.07) !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tab.ant-tabs-tab-active {
+  color: #1e3a8a !important;
+  background: rgba(255, 255, 255, 0.65) !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
+  color: inherit !important;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-tab-btn {
+  outline: none;
+  text-shadow: none;
+}
+
+.main-tabs.demand-nav-tabs .ant-tabs-ink-bar {
+  height: 3px !important;
+  border-radius: 3px !important;
+  background: linear-gradient(90deg, #2563eb 0%, #38bdf8 52%, #0ea5e9 100%) !important;
+  box-shadow: 0 -1px 8px rgba(37, 99, 235, 0.22);
+}
+
+@media (max-width: 600px) {
+  .main-tabs.demand-nav-tabs .ant-tabs-nav {
+    padding: 4px 8px 0;
+  }
+
+  .main-tabs.demand-nav-tabs .ant-tabs-tab {
+    padding: 14px 14px 11px !important;
+    font-size: 15px !important;
+  }
+}
+
+/* 主内容:单层「纸片」,分区线分隔;放在 Tab 内时与外框连成一体 */
+.panel-sheet {
   margin-top: 2px;
+  border-radius: 12px;
+  border: none;
+  background: #ffffff;
+  box-shadow: none;
+  overflow: hidden;
+}
+
+.main-tabs.demand-nav-tabs .panel-sheet {
+  margin-top: 0 !important;
+  border: none !important;
+  border-radius: 0 !important;
+  box-shadow: none !important;
+  background: transparent !important;
+}
+
+.panel-section {
+  padding: 20px 22px;
+}
+
+.panel-section--filters {
+  background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%);
+  border-bottom: none;
+}
+
+.panel-section--table {
+  padding-bottom: 18px;
+}
+
+.panel-section-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.panel-section-accent {
+  width: 3px;
+  height: 16px;
+  border-radius: 2px;
+  background: linear-gradient(180deg, #2563eb, #38bdf8);
+  flex-shrink: 0;
+}
+
+.panel-section-title {
+  margin: 0 !important;
+  font-size: 15px !important;
+  font-weight: 600 !important;
+  color: var(--text-primary) !important;
+  letter-spacing: 0.02em;
+}
+
+.panel-section-title--inline {
+  margin: 0 !important;
+}
+
+.table-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.table-toolbar-meta {
+  margin-left: auto;
+}
+
+.meta-chip {
+  display: inline-flex;
+  align-items: center;
+  height: 26px;
+  padding: 0 11px;
+  border-radius: 6px;
+  background: var(--surface-muted);
+  color: #475569;
+  font-size: 12px;
+  font-weight: 500;
+  border: none;
 }
 
 .filter-form {
@@ -80,7 +301,13 @@ body {
 }
 
 .filter-row.second-row {
-  margin-top: 4px;
+  margin-top: 2px;
+}
+
+/* 阳历/阴历筛选:加宽数字框,不改 filter-row 的 gap */
+.element-demand-filter-row .ant-input-number {
+  width: 220px;
+  max-width: 100%;
 }
 
 .strategy-item {
@@ -94,68 +321,68 @@ body {
   min-width: 0;
 }
 
-.footer-bar {
-  margin-top: 14px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  gap: 12px;
-  flex-wrap: wrap;
+.table-wrap {
+  border: none;
+  border-radius: 10px;
+  overflow: hidden;
 }
 
-.filter-card.ant-card,
-.table-card.ant-card {
-  margin-top: 16px;
-  border-radius: 14px;
-  border: 1px solid #e8eefb;
-  box-shadow: 0 8px 30px rgba(13, 46, 104, 0.06);
-  background: rgba(255, 255, 255, 0.9);
-  backdrop-filter: blur(4px);
+.panel-section--table .ant-table-wrapper .ant-table {
+  border-radius: 0;
 }
 
-.table-title-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 10px;
+.panel-section--table .ant-table-wrapper .ant-table,
+.panel-section--table .ant-table-wrapper .ant-table-container {
+  border: none !important;
 }
 
-.table-card .ant-table-wrapper .ant-table {
-  border-radius: 10px;
+.panel-section--table .ant-table-thead > tr > th {
+  background: #f1f5f9 !important;
+  color: #334155 !important;
+  font-weight: 600 !important;
+  border-bottom: none !important;
 }
 
-.table-card .ant-table-thead > tr > th {
-  background: #f2f6ff;
-  color: #334155;
-  font-weight: 600;
+.panel-section--table .ant-table-thead > tr > th::before {
+  display: none !important;
 }
 
-.table-card .row-even td {
-  background: #ffffff;
+.panel-section--table .ant-table-cell {
+  border-inline-end: none !important;
 }
 
-.table-card .row-odd td {
-  background: #fcfdff;
+.panel-section--table .ant-table-tbody > tr > td {
+  border-bottom: none !important;
 }
 
-.pill {
-  display: inline-flex;
-  align-items: center;
-  height: 28px;
-  padding: 0 10px;
-  border-radius: 999px;
-  background: #f1f5ff;
-  color: #3b4b73;
-  font-size: 12px;
-  border: 1px solid #dbe7ff;
+.panel-section--table .ant-table-tbody > tr:last-child > td {
+  border-bottom: none !important;
+}
+
+.panel-section--table .row-even td {
+  background: #ffffff !important;
+}
+
+.panel-section--table .row-odd td {
+  background: #fafbfc !important;
+}
+
+.panel-footer {
+  margin-top: 18px;
+  padding-top: 4px;
+  border-top: none;
+  display: flex;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  gap: 12px;
 }
 
-.table-card .ant-pagination-item-active {
-  border-color: #2563eb;
+.panel-footer .ant-pagination-item-active {
+  border-color: var(--accent);
 }
 
-.table-card .ant-pagination-item-active a {
-  color: #2563eb;
+.panel-footer .ant-pagination-item-active a {
+  color: var(--accent);
 }
 
 @media (max-width: 900px) {
@@ -163,4 +390,13 @@ body {
     width: 100%;
     min-width: 0;
   }
+
+  .table-toolbar {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .table-toolbar-meta {
+    margin-left: 0;
+  }
 }