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"; import HotContentSourcePage from "./HotContentSourcePage"; import DemandNavBar from "./DemandNavBar"; import EllipsisCell from "./EllipsisCell"; type DemandPoolItem = { id: number; strategy: string | null; demand_name: string | null; type: string | null; weight: number | null; video_count: number | null; dt: string | null; reason: 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/"; const HOT_DEMAND_POOL_STRATEGY = "新热事件"; type HotSourceViewParams = { demandName: string; demandType: string; dt: string; }; function readHotSourceViewFromUrl(): HotSourceViewParams | null { const params = new URLSearchParams(window.location.search); if (params.get("view") !== "hot-source") { return null; } const demandName = (params.get("demand_name") ?? "").trim(); const demandType = (params.get("demand_type") ?? "").trim(); const dt = (params.get("dt") ?? "").trim(); if (!demandName || !demandType || !dt) { return null; } return { demandName, demandType, dt }; } function buildHotSourceViewUrl(params: HotSourceViewParams): string { const search = new URLSearchParams({ view: "hot-source", demand_name: params.demandName, demand_type: params.demandType, dt: params.dt, }); return `${window.location.origin}${window.location.pathname}?${search.toString()}`; } function openHotSourceViewInNewTab(params: HotSourceViewParams) { window.open(buildHotSourceViewUrl(params), "_blank", "noopener,noreferrer"); } function closeHotSourceView() { if (window.opener && !window.opener.closed) { window.close(); return; } window.location.href = window.location.pathname; } function DemandPoolPanel() { const [strategies, setStrategies] = useState([]); 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"), }, { title: "原因", dataIndex: "reason", width: 140, ellipsis: true, render: (v, record) => (record.strategy ?? "").trim() === HOT_DEMAND_POOL_STRATEGY ? (v ?? "") : "", }, { title: "操作", key: "actions", width: 110, fixed: "right", render: (_, record) => { if ((record.strategy ?? "").trim() !== HOT_DEMAND_POOL_STRATEGY) { return "-"; } const demandName = (record.demand_name ?? "").trim(); const demandType = (record.type ?? "").trim(); const dt = (record.dt ?? "").trim(); const reason = (record.reason ?? "").trim(); if (!demandName || !demandType || !dt || reason) { return "-"; } return ( ); }, }, ], [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); }} />
); } type HotContentDemandExportItem = { id: number; source: string; hot_title: string; item_text: string; point_category: string; item_type: string; item_type_label: string; matched_demand: string; is_as_demand: number; is_as_demand_label: string; contribution_score: number | null; wxindex_keyword: string; all_hot_keywords: string; wxindex_latest_score: number; wxindex_trend: string; event_sense_score: number | null; senior_fit_score: number | null; record_created_at: string; }; type HotContentDemandExportResponse = { total: number; page: number; page_size: number; items: HotContentDemandExportItem[]; }; type IsAsDemandFilter = "all" | "yes" | "no"; type MatchedDemandFilter = "all" | "yes" | "no"; type ItemTypeFilter = "all" | "word" | "point"; function HotContentDemandExportPanel({ active }: { active: boolean }) { const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => { const today = dayjs(); return [today, today]; }); const [isAsDemandFilter, setIsAsDemandFilter] = useState("all"); const [matchedDemandFilter, setMatchedDemandFilter] = useState("all"); const [itemTypeFilter, setItemTypeFilter] = useState("all"); const [appliedIsAsDemand, setAppliedIsAsDemand] = useState("all"); const [appliedMatchedDemand, setAppliedMatchedDemand] = useState("all"); const [appliedItemType, setAppliedItemType] = useState("all"); const [minWxindexInput, setMinWxindexInput] = useState(null); const [appliedMinWxindex, setAppliedMinWxindex] = useState(null); const [minEventSenseInput, setMinEventSenseInput] = useState(null); const [appliedMinEventSense, setAppliedMinEventSense] = useState(null); const [minSeniorFitInput, setMinSeniorFitInput] = useState(null); const [appliedMinSeniorFit, setAppliedMinSeniorFit] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [refreshTick, setRefreshTick] = useState(0); const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(""); const [data, setData] = useState({ total: 0, page: 1, page_size: pageSize, items: [], }); const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? ""; const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? ""; const dateRangeInvalid = Boolean(startDate) && Boolean(endDate) && startDate > endDate; const appendFiltersToUrl = (url: URL) => { if (startDate) { url.searchParams.set("start_dt", startDate); } if (endDate) { url.searchParams.set("end_dt", endDate); } if (appliedIsAsDemand === "yes") { url.searchParams.set("is_as_demand", "1"); } else if (appliedIsAsDemand === "no") { url.searchParams.set("is_as_demand", "0"); } if (appliedMatchedDemand === "yes") { url.searchParams.set("has_matched_demand", "1"); } else if (appliedMatchedDemand === "no") { url.searchParams.set("has_matched_demand", "0"); } if (appliedItemType === "word") { url.searchParams.set("item_type", "词"); } else if (appliedItemType === "point") { url.searchParams.set("item_type", "点"); } if (appliedMinWxindex !== null) { url.searchParams.set("min_wxindex_latest_score", String(appliedMinWxindex)); } if (appliedMinEventSense !== null) { url.searchParams.set("min_event_sense_score", String(appliedMinEventSense)); } if (appliedMinSeniorFit !== null) { url.searchParams.set("min_senior_fit_score", String(appliedMinSeniorFit)); } }; const buildRequestUrl = (page: number, size: number = pageSize) => { const resolvedBase = getResolvedApiBaseUrl(); const baseWithSlash = resolvedBase.endsWith("/") ? resolvedBase : `${resolvedBase}/`; const url = new URL("hot-content/demand-exports", baseWithSlash); appendFiltersToUrl(url); url.searchParams.set("page", String(page)); url.searchParams.set("page_size", String(size)); return url.toString(); }; const buildExportUrl = () => { const resolvedBase = getResolvedApiBaseUrl(); const baseWithSlash = resolvedBase.endsWith("/") ? resolvedBase : `${resolvedBase}/`; const url = new URL("hot-content/demand-exports/export", baseWithSlash); appendFiltersToUrl(url); return url.toString(); }; const queryKey = JSON.stringify({ startDate, endDate, appliedIsAsDemand, appliedMatchedDemand, appliedItemType, appliedMinWxindex, appliedMinEventSense, appliedMinSeniorFit, currentPage, pageSize, refreshTick, active, }); const fetchData = async (page: number, size: number = pageSize) => { setLoading(true); setError(""); try { const response = await fetch(buildRequestUrl(page, size), { method: "GET", headers: { Accept: "application/json" }, }); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `HTTP ${response.status}`); } const payload = (await response.json()) as HotContentDemandExportResponse; setData(payload); } catch (queryError) { setError( queryError instanceof Error ? queryError.message : "查询失败,请重试", ); } finally { setLoading(false); setHasLoaded(true); } }; useEffect(() => { if (!active || dateRangeInvalid) { return; } void fetchData(currentPage, pageSize); }, [queryKey, dateRangeInvalid]); const handleSubmit = () => { if (dateRangeInvalid) { return; } setAppliedIsAsDemand(isAsDemandFilter); setAppliedMatchedDemand(matchedDemandFilter); setAppliedItemType(itemTypeFilter); setAppliedMinWxindex(minWxindexInput); setAppliedMinEventSense(minEventSenseInput); setAppliedMinSeniorFit(minSeniorFitInput); setCurrentPage(1); setRefreshTick((value) => value + 1); }; const handleExport = async () => { if (dateRangeInvalid) { return; } setExporting(true); try { await downloadExcelExport(buildExportUrl(), "新热事件查询.xlsx"); message.success("导出成功"); } catch { message.error("导出失败,请重试"); } finally { setExporting(false); } }; const resetFilters = () => { const today = dayjs(); setDateRange([today, today]); setIsAsDemandFilter("all"); setMatchedDemandFilter("all"); setItemTypeFilter("all"); setAppliedIsAsDemand("all"); setAppliedMatchedDemand("all"); setAppliedItemType("all"); setMinWxindexInput(null); setAppliedMinWxindex(null); setMinEventSenseInput(null); setAppliedMinEventSense(null); setMinSeniorFitInput(null); setAppliedMinSeniorFit(null); setCurrentPage(1); setRefreshTick((value) => value + 1); }; const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); const columns: ColumnsType = useMemo( () => [ { title: "来源", dataIndex: "source", width: 100, ellipsis: true, fixed: "left", render: (v: string) => , }, { title: "热点标题", dataIndex: "hot_title", width: 220, ellipsis: true, fixed: "left", render: (v: string) => , }, { title: "词条", dataIndex: "item_text", width: 160, ellipsis: true, fixed: "left", render: (v: string) => , }, { title: "类型", dataIndex: "point_category", width: 100, fixed: "left", render: (v: string) => , }, { title: "需求类型", dataIndex: "item_type_label", width: 90, align: "center", fixed: "left", }, { title: "是否成为需求", dataIndex: "is_as_demand_label", width: 120, align: "center", fixed: "left", }, { title: "匹配需求", dataIndex: "matched_demand", width: 200, ellipsis: true, render: (v: string) => , }, { title: "创建时间", dataIndex: "record_created_at", width: 170, }, { title: "贡献分", dataIndex: "contribution_score", width: 100, align: "right", render: (v: number | null) => v === null || v === undefined ? "-" : Number(v).toFixed(2), }, { title: "最高微信指数词", dataIndex: "wxindex_keyword", width: 160, ellipsis: true, render: (v: string) => , }, { title: "待选微信指数词", dataIndex: "all_hot_keywords", width: 200, ellipsis: true, render: (v: string) => , }, { title: "微信指数热度", dataIndex: "wxindex_latest_score", width: 120, align: "right", render: (v: number) => Number(v ?? 0).toLocaleString(), }, { title: "微信指数趋势", dataIndex: "wxindex_trend", width: 110, render: (v: string) => }, { title: "事件性得分", dataIndex: "event_sense_score", width: 110, align: "right", render: (v: number | null) => v === null || v === undefined ? "-" : Number(v).toFixed(2), }, { title: "老年性得分", dataIndex: "senior_fit_score", width: 110, align: "right", render: (v: number | null) => v === null || v === undefined ? "-" : Number(v).toFixed(2), }, ], [], ); return (
筛选条件
setDateRange(values as [Dayjs, Dayjs] | null)} allowClear={false} /> className="hot-content-filter-select" value={isAsDemandFilter} onChange={setIsAsDemandFilter} options={[ { label: "全部", value: "all" }, { label: "是", value: "yes" }, { label: "否", value: "no" }, ]} /> className="hot-content-filter-select" value={itemTypeFilter} onChange={setItemTypeFilter} options={[ { label: "全部", value: "all" }, { label: "特征点", value: "word" }, { label: "短语", value: "point" }, ]} /> className="hot-content-filter-select" value={matchedDemandFilter} onChange={setMatchedDemandFilter} options={[ { label: "全部", value: "all" }, { label: "是", value: "yes" }, { label: "否", value: "no" }, ]} /> setMinWxindexInput(v ?? null)} /> setMinEventSenseInput(v ?? null)} /> setMinSeniorFitInput(v ?? null)} />
{dateRangeInvalid ? ( ) : null} {error ? ( ) : null}
新热事件明细 共 {data.total} 条 第 {currentPage} / {totalPages} 页
{loading && !hasLoaded ? ( ) : (
(index % 2 === 0 ? "row-even" : "row-odd")} /> )}
`共 ${total} 条`} onChange={(page, size) => { const nextSize = size ?? pageSize; setCurrentPage(page); if (nextSize !== pageSize) { setPageSize(nextSize); } }} onShowSizeChange={(_, size) => { setPageSize(size); setCurrentPage(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 [hotSourceView, setHotSourceView] = useState(() => readHotSourceViewFromUrl(), ); useEffect(() => { const onPopState = () => { setHotSourceView(readHotSourceViewFromUrl()); }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []); const tabItems = useMemo( () => [ { key: "demand-pool", label: "需求池", children: , }, { key: "solar-calendar", label: "去年同期阳历特征点查询", children: ( ), }, { key: "lunar-calendar", label: "去年同期阴历特征点查询", children: ( ), }, { key: "monthly-demand", label: "逐月特征点查询", children: ( ), }, { key: "hot-content-demand", label: "新热事件查询", children: ( ), }, ], [activeTab], ); if (hotSourceView) { return ( ); } return ( <>
需求池数据看板
数据检索 策略筛选 日期范围分析 需求筛选
); } export default App;