import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Button, DatePicker, Form, Input, InputNumber, List, message, Modal, Pagination, Select, Skeleton, Space, Table, Tabs, Tag, Typography, } from "antd"; import type { ColumnsType } from "antd/es/table"; import type { SortOrder } from "antd/es/table/interface"; import dayjs from "dayjs"; import type { Dayjs } from "dayjs"; type DemandPoolItem = { id: number; strategy: string | null; demand_name: string | null; type: string | null; weight: number | null; video_count: number | null; dt: string | null; }; type QueryResponse = { total: number; page: number; page_size: number; items: DemandPoolItem[]; }; type StrategyOption = { strategy: string; record_count: number; }; 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; month_list?: string | null; frequency?: number | 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 []; } function formatYmDisplay(ym: string): string { if (/^\d{6}$/.test(ym)) { return `${ym.slice(0, 4)}-${ym.slice(4, 6)}`; } return ym; } function formatMonthListPreview(raw: string | null): string { const months = parseVideoIdsFromList(raw); if (months.length > 0) { return `共 ${months.length} 个月,点击查看`; } const trimmed = (raw ?? "").trim(); if (!trimmed) { return "-"; } return trimmed.length > 36 ? `${trimmed.slice(0, 36)}…` : trimmed; } const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1"; const getResolvedApiBaseUrl = () => { if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) { return API_BASE_URL; } return new URL(API_BASE_URL, window.location.origin).toString(); }; 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/"; function DemandPoolPanel() { const [strategies, setStrategies] = useState([]); const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => { const today = dayjs(); return [today, today]; }); const [strategyOptions, setStrategyOptions] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [refreshTick, setRefreshTick] = useState(0); const [minWeightInput, setMinWeightInput] = useState(null); const [maxWeightInput, setMaxWeightInput] = useState(null); const [appliedMinWeight, setAppliedMinWeight] = useState(null); const [appliedMaxWeight, setAppliedMaxWeight] = useState(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); const [exporting, setExporting] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [loadingStrategies, setLoadingStrategies] = useState(false); const [error, setError] = useState(""); const [data, setData] = useState({ 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 weightRangeInvalid = minWeightInput !== null && maxWeightInput !== null && minWeightInput > maxWeightInput; const queryKey = JSON.stringify({ strategies, startDate, endDate, appliedMinWeight, appliedMaxWeight, appliedDemandName, sortBy, sortOrder, currentPage, pageSize, refreshTick, }); const buildRequestUrl = (page: number, size: number = pageSize) => { const resolvedBase = getResolvedApiBaseUrl(); const baseWithSlash = resolvedBase.endsWith("/") ? resolvedBase : `${resolvedBase}/`; const url = new URL("demand-pool", 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); 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("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("/") ? resolvedBase : `${resolvedBase}/`; const url = new URL("demand-pool/strategies", baseWithSlash); 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)); } return url.toString(); }; 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) { throw new Error(`HTTP ${response.status}`); } const payload = (await response.json()) as QueryResponse; setData(payload); } catch (queryError) { setError( queryError instanceof Error ? queryError.message : "查询失败,请重试" ); } finally { setLoading(false); setHasLoaded(true); } }; const fetchStrategies = async () => { setLoadingStrategies(true); try { const response = await fetch(buildStrategyUrl(), { method: "GET", headers: { Accept: "application/json" } }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = (await response.json()) as StrategyResponse; setStrategyOptions(payload.items); const availableSet = new Set(payload.items.map((item) => item.strategy)); const allStrategies = payload.items.map((item) => item.strategy); setStrategies((prev) => { if (prev.length === 0) { return allStrategies; } const filtered = prev.filter((value) => availableSet.has(value)); return filtered.length > 0 ? filtered : allStrategies; }); } catch { setStrategyOptions([]); } finally { setLoadingStrategies(false); } }; useEffect(() => { void fetchStrategies(); }, [startDate, endDate, appliedMinWeight, appliedMaxWeight]); useEffect(() => { if (dateRangeInvalid || weightRangeInvalid || loadingStrategies) { return; } if (strategyOptions.length > 0 && strategies.length === 0) { return; } void fetchData(currentPage, pageSize); }, [queryKey, loadingStrategies, dateRangeInvalid, weightRangeInvalid, strategyOptions.length]); const handleSubmit = async () => { if (dateRangeInvalid || weightRangeInvalid) { return; } setAppliedMinWeight(minWeightInput); setAppliedMaxWeight(maxWeightInput); setAppliedDemandName(demandNameInput.trim()); setCurrentPage(1); setRefreshTick((value) => value + 1); }; const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); const handlePageSizeChange = (value: number) => { setPageSize(value); setCurrentPage(1); }; const resetFilters = () => { const today = dayjs(); setDateRange([today, today]); setStrategies(strategyOptions.map((item) => item.strategy)); setMinWeightInput(null); setMaxWeightInput(null); setAppliedMinWeight(null); setAppliedMaxWeight(null); setDemandNameInput(""); setAppliedDemandName(""); setSortBy("weight"); setSortOrder("desc"); setCurrentPage(1); }; const getSortOrderForColumn = (columnKey: string): SortOrder | null => { if (sortBy !== columnKey) { return null; } return sortOrder === "asc" ? "ascend" : "descend"; }; const columns: ColumnsType = useMemo( () => [ { title: "ID", dataIndex: "id", width: 90, sorter: true, sortOrder: getSortOrderForColumn("id") }, { title: "策略名", dataIndex: "strategy", render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("strategy"), }, { title: "需求名称", dataIndex: "demand_name", render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("demand_name"), }, { title: "需求类型", dataIndex: "type", width: 120, render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("type"), }, { title: "权重", dataIndex: "weight", width: 120, render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("weight"), }, { title: "视频数量", dataIndex: "video_count", width: 120, render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("video_count"), }, { title: "日期", dataIndex: "dt", width: 120, render: (v) => v ?? "-", sorter: true, sortOrder: getSortOrderForColumn("dt"), }, ], [sortBy, sortOrder] ); return (
筛选条件
void handleSubmit()} className="filter-form">
setDemandNameInput(e.target.value)} />
{dateRangeInvalid ? ( ) : null} {weightRangeInvalid ? ( ) : null} {error ? ( ) : null}
需求明细 已选策略 {strategies.length} 共 {data.total} 条 第 {currentPage} / {totalPages} 页
{loading && !hasLoaded ? ( ) : (
(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); }} /> )}
`共 ${total} 条`} onChange={(page, size) => { const nextSize = size ?? pageSize; setCurrentPage(page); if (nextSize !== pageSize) { setPageSize(nextSize); return; } }} onShowSizeChange={(_, size) => { handlePageSizeChange(size); }} />
); } 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([]); const [loading, setLoading] = useState(false); const [exporting, setExporting] = 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([]); const [videoModalRaw, setVideoModalRaw] = useState(""); const [monthModalOpen, setMonthModalOpen] = useState(false); const [monthModalTitleName, setMonthModalTitleName] = useState(""); const [monthModalMonths, setMonthModalMonths] = useState([]); const [monthModalRaw, setMonthModalRaw] = useState(""); const [decodeLoadingVid, setDecodeLoadingVid] = useState(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(); 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 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(""); 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 = useMemo( () => { const baseColumns: ColumnsType = [ { 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)), }, ]; const videoListColumn: ColumnsType[number] = { 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 ( { setVideoModalTitleName(record.demand_name ?? ""); setVideoModalIds(ids); setVideoModalRaw(raw); setVideoModalOpen(true); }} > {label} ); }, }; const videoCountColumn: ColumnsType[number] = { title: "视频数", dataIndex: "video_count", width: 100, render: (v) => v ?? "-", }; if (mode === "monthly") { baseColumns.push({ title: "频次", dataIndex: "frequency", width: 90, render: (v) => (v === null || v === undefined ? "-" : String(v)), }); baseColumns.push(videoCountColumn); baseColumns.push({ title: "月份列表", dataIndex: "month_list", width: 180, render: (_, record) => { const raw = record.month_list ?? ""; const trimmed = raw.trim(); if (!trimmed) { return "-"; } const months = parseVideoIdsFromList(raw); return ( { setMonthModalTitleName(record.demand_name ?? ""); setMonthModalMonths(months); setMonthModalRaw(raw); setMonthModalOpen(true); }} > {formatMonthListPreview(raw)} ); }, }); } else { baseColumns.push(videoCountColumn); } baseColumns.push(videoListColumn); return baseColumns; }, [mode] ); return (
筛选条件
void handleSubmit()} className="filter-form">
{mode === "period" ? ( setPeriodDays(v ?? 7)} /> ) : null} setViewPvCount(v ?? 0)} /> {mode === "monthly" ? ( setMonthTotalPvThreshold(v ?? 0)} /> ) : null} setMinContributionScore(v ?? 0)} /> setRovAvg(v ?? 0)} /> {mode === "monthly" ? ( setMinFrequency(v ?? 0)} /> ) : null}
{error ? ( ) : null}
{tableDetailTitle} 本地 {total} 条 第 {page} / {totalPages} 页
{loading && !hasLoaded ? ( ) : (
String(record.demand_id ?? "").trim() || `${record.strategy ?? ""}|${record.demand_name ?? ""}` } loading={loading} columns={columns} dataSource={pagedItems} pagination={false} scroll={{ x: mode === "monthly" ? 1150 : 880 }} rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")} /> )} setVideoModalOpen(false)} footer={ } width={900} destroyOnHidden > {videoModalIds.length > 0 ? ( (
{idx + 1}. {vid}
)} /> ) : ( {videoModalRaw || "(空)"} )}
setMonthModalOpen(false)} footer={ } width={560} destroyOnHidden > {monthModalMonths.length > 0 ? ( ( {idx + 1}. {formatYmDisplay(ym)} )} /> ) : ( {monthModalRaw || "(空)"} )}
`共 ${t} 条`} onChange={(nextPage, size) => { const nextSize = size ?? pageSize; if (nextSize !== pageSize) { setPageSize(nextSize); setPage(1); return; } setPage(nextPage); }} onShowSizeChange={(_, size) => { setPageSize(size); setPage(1); }} />
); } function SolarCalendarPanel({ active }: { active: boolean }) { return ( ); } function LunarCalendarPanel({ active }: { active: boolean }) { return ( ); } function MonthlyDemandPanel({ active }: { active: boolean }) { return ( ); } function App() { const [activeTab, setActiveTab] = useState("demand-pool"); const tabItems = useMemo( () => [ { key: "demand-pool", label: "需求池", children: , }, { key: "solar-calendar", label: "去年同期阳历特征点查询", children: ( ), }, { key: "lunar-calendar", label: "去年同期阴历特征点查询", children: ( ), }, { key: "monthly-demand", label: "逐月特征点查询", children: ( ), }, ], [activeTab], ); return (
需求池数据看板
数据检索 策略筛选 日期范围分析 需求筛选
); } export default App;