Przeglądaj źródła

增加视频详情

xueyiming 2 tygodni temu
rodzic
commit
b20b2cb959
3 zmienionych plików z 134 dodań i 7 usunięć
  1. 18 1
      app/api/routes.py
  2. 28 0
      app/services/element_search_service.py
  3. 88 6
      frontend/src/App.tsx

+ 18 - 1
app/api/routes.py

@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Query
+from fastapi import APIRouter, HTTPException, Query
 
 from app.core.config import settings
 from app.scheduler.manager import scheduler
@@ -10,6 +10,7 @@ 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,
+    query_video_decode_url2_for_today,
 )
 
 router = APIRouter()
@@ -156,6 +157,22 @@ async def get_element_demands_monthly(
     return {"items": items}
 
 
+@router.get("/videos/decode-url")
+async def get_video_decode_page_url(
+    vid: str = Query(
+        ...,
+        min_length=1,
+        max_length=128,
+        description="视频 id,对应 dwd_topic_decode_result_di.vid(当天上海分区)",
+    ),
+) -> dict[str, str | None]:
+    try:
+        url2 = query_video_decode_url2_for_today(vid)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    return {"url2": url2}
+
+
 @router.get("/demand-pool/strategies")
 async def get_demand_pool_strategies(
     start_dt: str | None = Query(default=None, description="开始日期: yyyymmdd 或 yyyy-mm-dd"),

+ 28 - 0
app/services/element_search_service.py

@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
 from app.odps.client import get_odps_client
 
 _DATE_PARTITION_RE = re.compile(r"^\d{8}$")
+_VID_QUERY_RE = re.compile(r"^[0-9a-zA-Z_-]{1,128}$")
 SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
 
 
@@ -421,3 +422,30 @@ ORDER BY r.avg_rov DESC
                 }
             )
     return rows
+
+
+def query_video_decode_url2_for_today(vid: str) -> str | None:
+    """按上海时区当天分区,从 dwd_topic_decode_result_di 取视频解构页 url2。"""
+    vid_clean = vid.strip()
+    if not _VID_QUERY_RE.match(vid_clean):
+        raise ValueError("vid 须为 1~128 位字母、数字、下划线或连字符")
+
+    bizdate = _validate_partition_dt("bizdate", _partition_yyyymmdd(_today_shanghai()))
+    sql = f"""
+SELECT  url2
+FROM    loghubods.dwd_topic_decode_result_di
+WHERE   dt = '{bizdate}'
+AND     CAST(vid AS STRING) = '{vid_clean}'
+LIMIT   1
+"""
+
+    odps_client = get_odps_client()
+    instance = odps_client.execute_sql(sql)
+    with instance.open_reader(tunnel=True) as reader:
+        for record in reader:
+            raw = record["url2"]
+            if raw is None:
+                return None
+            text_val = str(raw).strip()
+            return text_val if text_val else None
+    return None

+ 88 - 6
frontend/src/App.tsx

@@ -7,6 +7,7 @@ import {
   Input,
   InputNumber,
   List,
+  message,
   Modal,
   Pagination,
   Select,
@@ -87,6 +88,10 @@ const getResolvedApiBaseUrl = () => {
   return new URL(API_BASE_URL, window.location.origin).toString();
 };
 
+/** 票圈后台:视频详情页(新标签打开) */
+const CMS_VIDEO_POST_DETAIL_BASE =
+  "https://admin.piaoquantv.com/cms/post-detail/";
+
 function DemandPoolPanel() {
   const [strategies, setStrategies] = useState<string[]>([]);
   const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
@@ -550,6 +555,44 @@ function ElementDemandQueryPanel({
   const [videoModalTitleName, setVideoModalTitleName] = useState("");
   const [videoModalIds, setVideoModalIds] = useState<string[]>([]);
   const [videoModalRaw, setVideoModalRaw] = useState("");
+  const [decodeLoadingVid, setDecodeLoadingVid] = useState<string | null>(null);
+
+  const openVideoCmsDetail = useCallback((vid: string) => {
+    const url = `${CMS_VIDEO_POST_DETAIL_BASE}${encodeURIComponent(vid)}/detail`;
+    window.open(url, "_blank", "noopener,noreferrer");
+  }, []);
+
+  const openVideoDecodePage = useCallback(async (vid: string) => {
+    setDecodeLoadingVid(vid);
+    try {
+      const resolvedBase = getResolvedApiBaseUrl();
+      const baseWithSlash = resolvedBase.endsWith("/")
+        ? resolvedBase
+        : `${resolvedBase}/`;
+      const url = new URL("videos/decode-url", baseWithSlash);
+      url.searchParams.set("vid", vid);
+      const response = await fetch(url.toString(), {
+        method: "GET",
+        headers: { Accept: "application/json" },
+      });
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+      }
+      const payload = (await response.json()) as { url2?: string | null };
+      const raw = payload.url2;
+      const trimmed =
+        typeof raw === "string" ? raw.trim() : raw != null ? String(raw).trim() : "";
+      if (!trimmed) {
+        message.warning("不存在解构页面");
+        return;
+      }
+      window.open(trimmed, "_blank", "noopener,noreferrer");
+    } catch {
+      message.error("解构页面地址查询失败");
+    } finally {
+      setDecodeLoadingVid(null);
+    }
+  }, []);
 
   const buildQueryUrl = useCallback(() => {
     const resolvedBase = getResolvedApiBaseUrl();
@@ -827,7 +870,7 @@ function ElementDemandQueryPanel({
               关闭
             </Button>
           }
-          width={720}
+          width={900}
           destroyOnHidden
         >
           {videoModalIds.length > 0 ? (
@@ -837,11 +880,50 @@ function ElementDemandQueryPanel({
               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 style={{ paddingBlock: 10, paddingInline: 16 }}>
+                  <div
+                    style={{
+                      display: "flex",
+                      alignItems: "center",
+                      width: "100%",
+                      flexWrap: "nowrap",
+                      gap: 12,
+                    }}
+                  >
+                    <span
+                      style={{
+                        display: "inline-flex",
+                        alignItems: "baseline",
+                        gap: "0.25em",
+                        whiteSpace: "nowrap",
+                        minWidth: 0,
+                      }}
+                    >
+                      <Typography.Text type="secondary" style={{ margin: 0 }}>
+                        {idx + 1}.
+                      </Typography.Text>
+                      <Typography.Text copyable={{ text: vid }} style={{ margin: 0 }}>
+                        {vid}
+                      </Typography.Text>
+                    </span>
+                    <Space size={4} style={{ marginLeft: "auto", flexShrink: 0 }}>
+                      <Button
+                        type="link"
+                        size="small"
+                        onClick={() => openVideoCmsDetail(vid)}
+                      >
+                        查看视频详情
+                      </Button>
+                      <Button
+                        type="link"
+                        size="small"
+                        loading={decodeLoadingVid === vid}
+                        onClick={() => void openVideoDecodePage(vid)}
+                      >
+                        查看视频解构
+                      </Button>
+                    </Space>
+                  </div>
                 </List.Item>
               )}
             />