App.tsx 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938
  1. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  2. import {
  3. Alert,
  4. Button,
  5. DatePicker,
  6. Form,
  7. Input,
  8. InputNumber,
  9. List,
  10. message,
  11. Modal,
  12. Pagination,
  13. Select,
  14. Skeleton,
  15. Space,
  16. Table,
  17. Tabs,
  18. Tag,
  19. Typography,
  20. } from "antd";
  21. import type { ColumnsType } from "antd/es/table";
  22. import type { SortOrder } from "antd/es/table/interface";
  23. import dayjs from "dayjs";
  24. import type { Dayjs } from "dayjs";
  25. import HotContentSourcePage from "./HotContentSourcePage";
  26. import DemandNavBar from "./DemandNavBar";
  27. import EllipsisCell from "./EllipsisCell";
  28. type DemandPoolItem = {
  29. id: number;
  30. strategy: string | null;
  31. demand_name: string | null;
  32. type: string | null;
  33. weight: number | null;
  34. video_count: number | null;
  35. dt: string | null;
  36. reason: string | null;
  37. };
  38. type QueryResponse = {
  39. total: number;
  40. page: number;
  41. page_size: number;
  42. items: DemandPoolItem[];
  43. };
  44. type StrategyOption = {
  45. strategy: string;
  46. record_count: number;
  47. };
  48. type StrategyResponse = {
  49. items: StrategyOption[];
  50. };
  51. type ElementDemandItem = {
  52. strategy: string | null;
  53. demand_id: string | null;
  54. demand_name: string | null;
  55. weight: number | null;
  56. video_count: number | null;
  57. video_list: string | null;
  58. month_list?: string | null;
  59. frequency?: number | null;
  60. ext_info: string | null;
  61. };
  62. type ElementDemandResponse = {
  63. items: ElementDemandItem[];
  64. };
  65. function parseVideoIdsFromList(raw: string | null): string[] {
  66. const t = (raw ?? "").trim();
  67. if (!t) {
  68. return [];
  69. }
  70. try {
  71. const parsed: unknown = JSON.parse(t);
  72. if (Array.isArray(parsed)) {
  73. return parsed.map((item) => String(item));
  74. }
  75. } catch {
  76. /* 非 JSON 数组则走下方原始文本展示 */
  77. }
  78. return [];
  79. }
  80. function formatYmDisplay(ym: string): string {
  81. if (/^\d{6}$/.test(ym)) {
  82. return `${ym.slice(0, 4)}-${ym.slice(4, 6)}`;
  83. }
  84. return ym;
  85. }
  86. function formatMonthListPreview(raw: string | null): string {
  87. const months = parseVideoIdsFromList(raw);
  88. if (months.length > 0) {
  89. return `共 ${months.length} 个月,点击查看`;
  90. }
  91. const trimmed = (raw ?? "").trim();
  92. if (!trimmed) {
  93. return "-";
  94. }
  95. return trimmed.length > 36 ? `${trimmed.slice(0, 36)}…` : trimmed;
  96. }
  97. const API_BASE_URL =
  98. import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
  99. const getResolvedApiBaseUrl = () => {
  100. if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
  101. return API_BASE_URL;
  102. }
  103. return new URL(API_BASE_URL, window.location.origin).toString();
  104. };
  105. function parseFilenameFromContentDisposition(header: string | null): string | null {
  106. if (!header) {
  107. return null;
  108. }
  109. const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
  110. if (utf8Match?.[1]) {
  111. try {
  112. return decodeURIComponent(utf8Match[1]);
  113. } catch {
  114. return utf8Match[1];
  115. }
  116. }
  117. const asciiMatch = header.match(/filename="([^"]+)"/i);
  118. return asciiMatch?.[1] ?? null;
  119. }
  120. async function downloadExcelExport(url: string, defaultFilename: string) {
  121. const response = await fetch(url, { method: "GET" });
  122. if (!response.ok) {
  123. throw new Error(`HTTP ${response.status}`);
  124. }
  125. const blob = await response.blob();
  126. const filename =
  127. parseFilenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
  128. defaultFilename;
  129. const objectUrl = URL.createObjectURL(blob);
  130. const link = document.createElement("a");
  131. link.href = objectUrl;
  132. link.download = filename;
  133. link.click();
  134. URL.revokeObjectURL(objectUrl);
  135. }
  136. /** 票圈后台:视频详情页(新标签打开) */
  137. const CMS_VIDEO_POST_DETAIL_BASE =
  138. "https://admin.piaoquantv.com/cms/post-detail/";
  139. const HOT_DEMAND_POOL_STRATEGY = "新热事件";
  140. type HotSourceViewParams = {
  141. demandName: string;
  142. demandType: string;
  143. dt: string;
  144. };
  145. function readHotSourceViewFromUrl(): HotSourceViewParams | null {
  146. const params = new URLSearchParams(window.location.search);
  147. if (params.get("view") !== "hot-source") {
  148. return null;
  149. }
  150. const demandName = (params.get("demand_name") ?? "").trim();
  151. const demandType = (params.get("demand_type") ?? "").trim();
  152. const dt = (params.get("dt") ?? "").trim();
  153. if (!demandName || !demandType || !dt) {
  154. return null;
  155. }
  156. return { demandName, demandType, dt };
  157. }
  158. function buildHotSourceViewUrl(params: HotSourceViewParams): string {
  159. const search = new URLSearchParams({
  160. view: "hot-source",
  161. demand_name: params.demandName,
  162. demand_type: params.demandType,
  163. dt: params.dt,
  164. });
  165. return `${window.location.origin}${window.location.pathname}?${search.toString()}`;
  166. }
  167. function openHotSourceViewInNewTab(params: HotSourceViewParams) {
  168. window.open(buildHotSourceViewUrl(params), "_blank", "noopener,noreferrer");
  169. }
  170. function closeHotSourceView() {
  171. if (window.opener && !window.opener.closed) {
  172. window.close();
  173. return;
  174. }
  175. window.location.href = window.location.pathname;
  176. }
  177. function DemandPoolPanel() {
  178. const [strategies, setStrategies] = useState<string[]>([]);
  179. const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
  180. const today = dayjs();
  181. return [today, today];
  182. });
  183. const [strategyOptions, setStrategyOptions] = useState<StrategyOption[]>([]);
  184. const [currentPage, setCurrentPage] = useState(1);
  185. const [pageSize, setPageSize] = useState(20);
  186. const [refreshTick, setRefreshTick] = useState(0);
  187. const [minWeightInput, setMinWeightInput] = useState<number | null>(null);
  188. const [maxWeightInput, setMaxWeightInput] = useState<number | null>(null);
  189. const [appliedMinWeight, setAppliedMinWeight] = useState<number | null>(null);
  190. const [appliedMaxWeight, setAppliedMaxWeight] = useState<number | null>(null);
  191. const [demandNameInput, setDemandNameInput] = useState("");
  192. const [appliedDemandName, setAppliedDemandName] = useState("");
  193. const [sortBy, setSortBy] = useState("weight");
  194. const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
  195. const [loading, setLoading] = useState(false);
  196. const [exporting, setExporting] = useState(false);
  197. const [hasLoaded, setHasLoaded] = useState(false);
  198. const [loadingStrategies, setLoadingStrategies] = useState(false);
  199. const [error, setError] = useState("");
  200. const [data, setData] = useState<QueryResponse>({
  201. total: 0,
  202. page: 1,
  203. page_size: pageSize,
  204. items: []
  205. });
  206. const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? "";
  207. const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? "";
  208. const dateRangeInvalid =
  209. Boolean(startDate) && Boolean(endDate) && startDate > endDate;
  210. const weightRangeInvalid =
  211. minWeightInput !== null && maxWeightInput !== null && minWeightInput > maxWeightInput;
  212. const queryKey = JSON.stringify({
  213. strategies,
  214. startDate,
  215. endDate,
  216. appliedMinWeight,
  217. appliedMaxWeight,
  218. appliedDemandName,
  219. sortBy,
  220. sortOrder,
  221. currentPage,
  222. pageSize,
  223. refreshTick,
  224. });
  225. const buildRequestUrl = (page: number, size: number = pageSize) => {
  226. const resolvedBase = getResolvedApiBaseUrl();
  227. const baseWithSlash = resolvedBase.endsWith("/")
  228. ? resolvedBase
  229. : `${resolvedBase}/`;
  230. const url = new URL("demand-pool", baseWithSlash);
  231. for (const strategyValue of strategies) {
  232. url.searchParams.append("strategy", strategyValue);
  233. }
  234. if (startDate) {
  235. url.searchParams.set("start_dt", startDate);
  236. }
  237. if (endDate) {
  238. url.searchParams.set("end_dt", endDate);
  239. }
  240. if (appliedMinWeight !== null) {
  241. url.searchParams.set("min_weight", String(appliedMinWeight));
  242. }
  243. if (appliedMaxWeight !== null) {
  244. url.searchParams.set("max_weight", String(appliedMaxWeight));
  245. }
  246. const trimmedDemandName = appliedDemandName.trim();
  247. if (trimmedDemandName) {
  248. url.searchParams.set("demand_name", trimmedDemandName);
  249. }
  250. url.searchParams.set("sort_by", sortBy);
  251. url.searchParams.set("sort_order", sortOrder);
  252. url.searchParams.set("page", String(page));
  253. url.searchParams.set("page_size", String(size));
  254. return url.toString();
  255. };
  256. const buildExportUrl = () => {
  257. const resolvedBase = getResolvedApiBaseUrl();
  258. const baseWithSlash = resolvedBase.endsWith("/")
  259. ? resolvedBase
  260. : `${resolvedBase}/`;
  261. const url = new URL("demand-pool/export", baseWithSlash);
  262. for (const strategyValue of strategies) {
  263. url.searchParams.append("strategy", strategyValue);
  264. }
  265. if (startDate) {
  266. url.searchParams.set("start_dt", startDate);
  267. }
  268. if (endDate) {
  269. url.searchParams.set("end_dt", endDate);
  270. }
  271. if (appliedMinWeight !== null) {
  272. url.searchParams.set("min_weight", String(appliedMinWeight));
  273. }
  274. if (appliedMaxWeight !== null) {
  275. url.searchParams.set("max_weight", String(appliedMaxWeight));
  276. }
  277. const trimmedDemandName = appliedDemandName.trim();
  278. if (trimmedDemandName) {
  279. url.searchParams.set("demand_name", trimmedDemandName);
  280. }
  281. url.searchParams.set("sort_by", sortBy);
  282. url.searchParams.set("sort_order", sortOrder);
  283. return url.toString();
  284. };
  285. const handleExport = async () => {
  286. if (dateRangeInvalid || weightRangeInvalid) {
  287. return;
  288. }
  289. setExporting(true);
  290. try {
  291. await downloadExcelExport(buildExportUrl(), "需求池.xlsx");
  292. message.success("导出成功");
  293. } catch {
  294. message.error("导出失败,请重试");
  295. } finally {
  296. setExporting(false);
  297. }
  298. };
  299. const buildStrategyUrl = () => {
  300. const resolvedBase = getResolvedApiBaseUrl();
  301. const baseWithSlash = resolvedBase.endsWith("/")
  302. ? resolvedBase
  303. : `${resolvedBase}/`;
  304. const url = new URL("demand-pool/strategies", baseWithSlash);
  305. if (startDate) {
  306. url.searchParams.set("start_dt", startDate);
  307. }
  308. if (endDate) {
  309. url.searchParams.set("end_dt", endDate);
  310. }
  311. if (appliedMinWeight !== null) {
  312. url.searchParams.set("min_weight", String(appliedMinWeight));
  313. }
  314. if (appliedMaxWeight !== null) {
  315. url.searchParams.set("max_weight", String(appliedMaxWeight));
  316. }
  317. return url.toString();
  318. };
  319. const fetchData = async (page: number, size: number = pageSize) => {
  320. setLoading(true);
  321. setError("");
  322. try {
  323. const response = await fetch(buildRequestUrl(page, size), {
  324. method: "GET",
  325. headers: { Accept: "application/json" }
  326. });
  327. if (!response.ok) {
  328. throw new Error(`HTTP ${response.status}`);
  329. }
  330. const payload = (await response.json()) as QueryResponse;
  331. setData(payload);
  332. } catch (queryError) {
  333. setError(
  334. queryError instanceof Error ? queryError.message : "查询失败,请重试"
  335. );
  336. } finally {
  337. setLoading(false);
  338. setHasLoaded(true);
  339. }
  340. };
  341. const fetchStrategies = async () => {
  342. setLoadingStrategies(true);
  343. try {
  344. const response = await fetch(buildStrategyUrl(), {
  345. method: "GET",
  346. headers: { Accept: "application/json" }
  347. });
  348. if (!response.ok) {
  349. throw new Error(`HTTP ${response.status}`);
  350. }
  351. const payload = (await response.json()) as StrategyResponse;
  352. setStrategyOptions(payload.items);
  353. const availableSet = new Set(payload.items.map((item) => item.strategy));
  354. const allStrategies = payload.items.map((item) => item.strategy);
  355. setStrategies((prev) => {
  356. if (prev.length === 0) {
  357. return allStrategies;
  358. }
  359. const filtered = prev.filter((value) => availableSet.has(value));
  360. return filtered.length > 0 ? filtered : allStrategies;
  361. });
  362. } catch {
  363. setStrategyOptions([]);
  364. } finally {
  365. setLoadingStrategies(false);
  366. }
  367. };
  368. useEffect(() => {
  369. void fetchStrategies();
  370. }, [startDate, endDate, appliedMinWeight, appliedMaxWeight]);
  371. useEffect(() => {
  372. if (dateRangeInvalid || weightRangeInvalid || loadingStrategies) {
  373. return;
  374. }
  375. if (strategyOptions.length > 0 && strategies.length === 0) {
  376. return;
  377. }
  378. void fetchData(currentPage, pageSize);
  379. }, [queryKey, loadingStrategies, dateRangeInvalid, weightRangeInvalid, strategyOptions.length]);
  380. const handleSubmit = async () => {
  381. if (dateRangeInvalid || weightRangeInvalid) {
  382. return;
  383. }
  384. setAppliedMinWeight(minWeightInput);
  385. setAppliedMaxWeight(maxWeightInput);
  386. setAppliedDemandName(demandNameInput.trim());
  387. setCurrentPage(1);
  388. setRefreshTick((value) => value + 1);
  389. };
  390. const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
  391. const handlePageSizeChange = (value: number) => {
  392. setPageSize(value);
  393. setCurrentPage(1);
  394. };
  395. const resetFilters = () => {
  396. const today = dayjs();
  397. setDateRange([today, today]);
  398. setStrategies(strategyOptions.map((item) => item.strategy));
  399. setMinWeightInput(null);
  400. setMaxWeightInput(null);
  401. setAppliedMinWeight(null);
  402. setAppliedMaxWeight(null);
  403. setDemandNameInput("");
  404. setAppliedDemandName("");
  405. setSortBy("weight");
  406. setSortOrder("desc");
  407. setCurrentPage(1);
  408. };
  409. const getSortOrderForColumn = (columnKey: string): SortOrder | null => {
  410. if (sortBy !== columnKey) {
  411. return null;
  412. }
  413. return sortOrder === "asc" ? "ascend" : "descend";
  414. };
  415. const columns: ColumnsType<DemandPoolItem> = useMemo(
  416. () => [
  417. { title: "ID", dataIndex: "id", width: 90, sorter: true, sortOrder: getSortOrderForColumn("id") },
  418. {
  419. title: "策略名",
  420. dataIndex: "strategy",
  421. render: (v) => v ?? "-",
  422. sorter: true,
  423. sortOrder: getSortOrderForColumn("strategy"),
  424. },
  425. {
  426. title: "需求名称",
  427. dataIndex: "demand_name",
  428. render: (v) => v ?? "-",
  429. sorter: true,
  430. sortOrder: getSortOrderForColumn("demand_name"),
  431. },
  432. {
  433. title: "需求类型",
  434. dataIndex: "type",
  435. width: 120,
  436. render: (v) => v ?? "-",
  437. sorter: true,
  438. sortOrder: getSortOrderForColumn("type"),
  439. },
  440. {
  441. title: "权重",
  442. dataIndex: "weight",
  443. width: 120,
  444. render: (v) => v ?? "-",
  445. sorter: true,
  446. sortOrder: getSortOrderForColumn("weight"),
  447. },
  448. {
  449. title: "视频数量",
  450. dataIndex: "video_count",
  451. width: 120,
  452. render: (v) => v ?? "-",
  453. sorter: true,
  454. sortOrder: getSortOrderForColumn("video_count"),
  455. },
  456. {
  457. title: "日期",
  458. dataIndex: "dt",
  459. width: 120,
  460. render: (v) => v ?? "-",
  461. sorter: true,
  462. sortOrder: getSortOrderForColumn("dt"),
  463. },
  464. {
  465. title: "原因",
  466. dataIndex: "reason",
  467. width: 140,
  468. ellipsis: true,
  469. render: (v, record) =>
  470. (record.strategy ?? "").trim() === HOT_DEMAND_POOL_STRATEGY ? (v ?? "") : "",
  471. },
  472. {
  473. title: "操作",
  474. key: "actions",
  475. width: 110,
  476. fixed: "right",
  477. render: (_, record) => {
  478. if ((record.strategy ?? "").trim() !== HOT_DEMAND_POOL_STRATEGY) {
  479. return "-";
  480. }
  481. const demandName = (record.demand_name ?? "").trim();
  482. const demandType = (record.type ?? "").trim();
  483. const dt = (record.dt ?? "").trim();
  484. const reason = (record.reason ?? "").trim();
  485. if (!demandName || !demandType || !dt || reason) {
  486. return "-";
  487. }
  488. return (
  489. <Button
  490. type="link"
  491. size="small"
  492. onClick={() =>
  493. openHotSourceViewInNewTab({
  494. demandName,
  495. demandType,
  496. dt,
  497. })
  498. }
  499. >
  500. 查看来源
  501. </Button>
  502. );
  503. },
  504. },
  505. ],
  506. [sortBy, sortOrder]
  507. );
  508. return (
  509. <div className="panel-sheet">
  510. <section className="panel-section panel-section--filters">
  511. <header className="panel-section-head">
  512. <span className="panel-section-accent" aria-hidden />
  513. <Typography.Title level={5} className="panel-section-title">
  514. 筛选条件
  515. </Typography.Title>
  516. </header>
  517. <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
  518. <div className="filter-row">
  519. <Form.Item label="策略名" className="strategy-item">
  520. <Select
  521. className="strategy-select"
  522. placeholder="请选择策略(支持多选)"
  523. value={strategies}
  524. onChange={setStrategies}
  525. loading={loadingStrategies}
  526. mode="multiple"
  527. allowClear
  528. maxTagCount="responsive"
  529. showSearch
  530. optionFilterProp="label"
  531. options={strategyOptions.map((item) => ({
  532. label: `${item.strategy} (${item.record_count})`,
  533. value: item.strategy,
  534. }))}
  535. />
  536. </Form.Item>
  537. </div>
  538. <div className="filter-row second-row">
  539. <Form.Item label="日期区间">
  540. <DatePicker.RangePicker
  541. value={dateRange}
  542. onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
  543. allowClear
  544. />
  545. </Form.Item>
  546. <Form.Item label="权重分">
  547. <Space>
  548. <InputNumber
  549. placeholder="最小值"
  550. value={minWeightInput}
  551. onChange={(value) => setMinWeightInput(value)}
  552. />
  553. <span>-</span>
  554. <InputNumber
  555. placeholder="最大值"
  556. value={maxWeightInput}
  557. onChange={(value) => setMaxWeightInput(value)}
  558. />
  559. </Space>
  560. </Form.Item>
  561. <Form.Item label="需求名称">
  562. <Input
  563. allowClear
  564. value={demandNameInput}
  565. onChange={(e) => setDemandNameInput(e.target.value)}
  566. />
  567. </Form.Item>
  568. <Form.Item label=" ">
  569. <Button
  570. type="primary"
  571. htmlType="submit"
  572. loading={loading}
  573. disabled={dateRangeInvalid || weightRangeInvalid}
  574. >
  575. 查询
  576. </Button>
  577. </Form.Item>
  578. <Form.Item label=" ">
  579. <Space>
  580. <Button type="default" onClick={resetFilters}>
  581. 重置
  582. </Button>
  583. </Space>
  584. </Form.Item>
  585. </div>
  586. </Form>
  587. {dateRangeInvalid ? (
  588. <Alert
  589. style={{ marginTop: 12 }}
  590. type="error"
  591. showIcon
  592. message="开始日期不能晚于结束日期"
  593. />
  594. ) : null}
  595. {weightRangeInvalid ? (
  596. <Alert
  597. style={{ marginTop: 12 }}
  598. type="error"
  599. showIcon
  600. message="最小权重不能大于最大权重"
  601. />
  602. ) : null}
  603. {error ? (
  604. <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
  605. ) : null}
  606. </section>
  607. <section className="panel-section panel-section--table">
  608. <div className="table-toolbar">
  609. <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
  610. 需求明细
  611. </Typography.Title>
  612. <Space size={8} wrap className="table-toolbar-meta">
  613. <span className="meta-chip">已选策略 {strategies.length}</span>
  614. <span className="meta-chip">共 {data.total} 条</span>
  615. <span className="meta-chip">
  616. 第 {currentPage} / {totalPages} 页
  617. </span>
  618. <Button
  619. type="default"
  620. loading={exporting}
  621. disabled={dateRangeInvalid || weightRangeInvalid || loading}
  622. onClick={() => void handleExport()}
  623. >
  624. 导出 Excel
  625. </Button>
  626. </Space>
  627. </div>
  628. {loading && !hasLoaded ? (
  629. <Skeleton active paragraph={{ rows: 10 }} />
  630. ) : (
  631. <div className="table-wrap">
  632. <Table
  633. rowKey="id"
  634. loading={loading}
  635. columns={columns}
  636. dataSource={data.items}
  637. pagination={false}
  638. scroll={{ x: 1320 }}
  639. rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
  640. onChange={(_, __, sorter) => {
  641. if (Array.isArray(sorter)) {
  642. return;
  643. }
  644. const nextField = typeof sorter.field === "string" ? sorter.field : null;
  645. const nextOrder = sorter.order;
  646. if (!nextField || !nextOrder) {
  647. setSortBy("weight");
  648. setSortOrder("desc");
  649. setCurrentPage(1);
  650. return;
  651. }
  652. setSortBy(nextField);
  653. setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
  654. setCurrentPage(1);
  655. }}
  656. />
  657. </div>
  658. )}
  659. <div className="panel-footer">
  660. <Pagination
  661. current={currentPage}
  662. total={data.total}
  663. pageSize={pageSize}
  664. showSizeChanger
  665. pageSizeOptions={["10", "20", "50", "100"]}
  666. showQuickJumper
  667. showTotal={(total) => `共 ${total} 条`}
  668. onChange={(page, size) => {
  669. const nextSize = size ?? pageSize;
  670. setCurrentPage(page);
  671. if (nextSize !== pageSize) {
  672. setPageSize(nextSize);
  673. return;
  674. }
  675. }}
  676. onShowSizeChange={(_, size) => {
  677. handlePageSizeChange(size);
  678. }}
  679. />
  680. </div>
  681. </section>
  682. </div>
  683. );
  684. }
  685. function ElementDemandQueryPanel({
  686. active,
  687. apiPath,
  688. periodDaysLabel,
  689. tableDetailTitle,
  690. mode = "period",
  691. }: {
  692. active: boolean;
  693. apiPath: string;
  694. periodDaysLabel: string;
  695. tableDetailTitle: string;
  696. /** period:阳历/阴历同期;monthly:逐月回溯窗口 */
  697. mode?: "period" | "monthly";
  698. }) {
  699. const [periodDays, setPeriodDays] = useState(7);
  700. const [monthTotalPvThreshold, setMonthTotalPvThreshold] = useState(20000);
  701. const [minFrequency, setMinFrequency] = useState(4);
  702. const [viewPvCount, setViewPvCount] = useState(2000);
  703. const [minContributionScore, setMinContributionScore] = useState(0.8);
  704. const [rovAvg, setRovAvg] = useState(0.04);
  705. const [items, setItems] = useState<ElementDemandItem[]>([]);
  706. const [loading, setLoading] = useState(false);
  707. const [exporting, setExporting] = useState(false);
  708. const [hasLoaded, setHasLoaded] = useState(false);
  709. const [error, setError] = useState("");
  710. const [page, setPage] = useState(1);
  711. const [pageSize, setPageSize] = useState(20);
  712. const [videoModalOpen, setVideoModalOpen] = useState(false);
  713. const [videoModalTitleName, setVideoModalTitleName] = useState("");
  714. const [videoModalIds, setVideoModalIds] = useState<string[]>([]);
  715. const [videoModalRaw, setVideoModalRaw] = useState("");
  716. const [monthModalOpen, setMonthModalOpen] = useState(false);
  717. const [monthModalTitleName, setMonthModalTitleName] = useState("");
  718. const [monthModalMonths, setMonthModalMonths] = useState<string[]>([]);
  719. const [monthModalRaw, setMonthModalRaw] = useState("");
  720. const [decodeLoadingVid, setDecodeLoadingVid] = useState<string | null>(null);
  721. const openVideoCmsDetail = useCallback((vid: string) => {
  722. const url = `${CMS_VIDEO_POST_DETAIL_BASE}${encodeURIComponent(vid)}/detail`;
  723. window.open(url, "_blank", "noopener,noreferrer");
  724. }, []);
  725. const openVideoDecodePage = useCallback(async (vid: string) => {
  726. setDecodeLoadingVid(vid);
  727. try {
  728. const resolvedBase = getResolvedApiBaseUrl();
  729. const baseWithSlash = resolvedBase.endsWith("/")
  730. ? resolvedBase
  731. : `${resolvedBase}/`;
  732. const url = new URL("videos/decode-url", baseWithSlash);
  733. url.searchParams.set("vid", vid);
  734. const response = await fetch(url.toString(), {
  735. method: "GET",
  736. headers: { Accept: "application/json" },
  737. });
  738. if (!response.ok) {
  739. throw new Error(`HTTP ${response.status}`);
  740. }
  741. const payload = (await response.json()) as { url2?: string | null };
  742. const raw = payload.url2;
  743. const trimmed =
  744. typeof raw === "string" ? raw.trim() : raw != null ? String(raw).trim() : "";
  745. if (!trimmed) {
  746. message.warning("不存在解构页面");
  747. return;
  748. }
  749. window.open(trimmed, "_blank", "noopener,noreferrer");
  750. } catch {
  751. message.error("解构页面地址查询失败");
  752. } finally {
  753. setDecodeLoadingVid(null);
  754. }
  755. }, []);
  756. const buildQueryUrl = useCallback(() => {
  757. const resolvedBase = getResolvedApiBaseUrl();
  758. const baseWithSlash = resolvedBase.endsWith("/")
  759. ? resolvedBase
  760. : `${resolvedBase}/`;
  761. const url = new URL(apiPath, baseWithSlash);
  762. if (mode === "period") {
  763. url.searchParams.set("period_days", String(periodDays));
  764. } else {
  765. url.searchParams.set(
  766. "month_total_pv_threshold",
  767. String(monthTotalPvThreshold)
  768. );
  769. url.searchParams.set("min_frequency", String(minFrequency));
  770. }
  771. url.searchParams.set("view_pv_count", String(viewPvCount));
  772. url.searchParams.set("min_contribution_score", String(minContributionScore));
  773. url.searchParams.set("rov_avg", String(rovAvg));
  774. return url.toString();
  775. }, [
  776. apiPath,
  777. mode,
  778. periodDays,
  779. monthTotalPvThreshold,
  780. minFrequency,
  781. viewPvCount,
  782. minContributionScore,
  783. rovAvg,
  784. ]);
  785. const buildExportUrl = useCallback(() => {
  786. const resolvedBase = getResolvedApiBaseUrl();
  787. const baseWithSlash = resolvedBase.endsWith("/")
  788. ? resolvedBase
  789. : `${resolvedBase}/`;
  790. const url = new URL(`${apiPath}/export`, baseWithSlash);
  791. if (mode === "period") {
  792. url.searchParams.set("period_days", String(periodDays));
  793. } else {
  794. url.searchParams.set(
  795. "month_total_pv_threshold",
  796. String(monthTotalPvThreshold)
  797. );
  798. url.searchParams.set("min_frequency", String(minFrequency));
  799. }
  800. url.searchParams.set("view_pv_count", String(viewPvCount));
  801. url.searchParams.set("min_contribution_score", String(minContributionScore));
  802. url.searchParams.set("rov_avg", String(rovAvg));
  803. return url.toString();
  804. }, [
  805. apiPath,
  806. mode,
  807. periodDays,
  808. monthTotalPvThreshold,
  809. minFrequency,
  810. viewPvCount,
  811. minContributionScore,
  812. rovAvg,
  813. ]);
  814. const handleExport = async () => {
  815. setExporting(true);
  816. try {
  817. await downloadExcelExport(buildExportUrl(), "特征点.xlsx");
  818. message.success("导出成功");
  819. } catch {
  820. message.error("导出失败,请重试");
  821. } finally {
  822. setExporting(false);
  823. }
  824. };
  825. const fetchAll = useCallback(async () => {
  826. setLoading(true);
  827. setError("");
  828. try {
  829. const response = await fetch(buildQueryUrl(), {
  830. method: "GET",
  831. headers: { Accept: "application/json" },
  832. });
  833. if (!response.ok) {
  834. throw new Error(`HTTP ${response.status}`);
  835. }
  836. const payload = (await response.json()) as ElementDemandResponse;
  837. setItems(payload.items ?? []);
  838. setPage(1);
  839. } catch (queryError) {
  840. setError(
  841. queryError instanceof Error ? queryError.message : "查询失败,请重试"
  842. );
  843. setItems([]);
  844. } finally {
  845. setLoading(false);
  846. setHasLoaded(true);
  847. }
  848. }, [buildQueryUrl]);
  849. /** 仅在本会话内首次进入该 Tab 时自动请求一次;数据留在 state 中,切走再回来不重复请求 */
  850. const autoFetchedOnceRef = useRef(false);
  851. useEffect(() => {
  852. if (!active) {
  853. return;
  854. }
  855. if (autoFetchedOnceRef.current) {
  856. return;
  857. }
  858. autoFetchedOnceRef.current = true;
  859. void fetchAll();
  860. }, [active, fetchAll]);
  861. const handleSubmit = () => {
  862. void fetchAll();
  863. };
  864. const resetDefaults = () => {
  865. setPeriodDays(7);
  866. setMonthTotalPvThreshold(20000);
  867. setMinFrequency(4);
  868. setViewPvCount(2000);
  869. setMinContributionScore(0.8);
  870. setRovAvg(0.04);
  871. setPage(1);
  872. };
  873. const total = items.length;
  874. const totalPages = Math.max(1, Math.ceil(total / pageSize));
  875. const pagedItems = useMemo(() => {
  876. const start = (page - 1) * pageSize;
  877. return items.slice(start, start + pageSize);
  878. }, [items, page, pageSize]);
  879. const columns: ColumnsType<ElementDemandItem> = useMemo(
  880. () => {
  881. const baseColumns: ColumnsType<ElementDemandItem> = [
  882. {
  883. title: "策略",
  884. dataIndex: "strategy",
  885. width: 120,
  886. render: (v) => v ?? "-",
  887. },
  888. {
  889. title: "特征点名称",
  890. dataIndex: "demand_name",
  891. ellipsis: true,
  892. render: (v) => v ?? "-",
  893. },
  894. {
  895. title: "权重",
  896. dataIndex: "weight",
  897. width: 110,
  898. render: (v) => (v === null || v === undefined ? "-" : String(v)),
  899. },
  900. ];
  901. const videoListColumn: ColumnsType<ElementDemandItem>[number] = {
  902. title: "视频列表",
  903. dataIndex: "video_list",
  904. width: 220,
  905. render: (_, record) => {
  906. const raw = record.video_list ?? "";
  907. const trimmed = raw.trim();
  908. if (!trimmed) {
  909. return "-";
  910. }
  911. const ids = parseVideoIdsFromList(raw);
  912. const label =
  913. ids.length > 0
  914. ? `共 ${ids.length} 条,点击查看`
  915. : trimmed.length > 36
  916. ? `${trimmed.slice(0, 36)}…`
  917. : trimmed;
  918. return (
  919. <Typography.Link
  920. onClick={() => {
  921. setVideoModalTitleName(record.demand_name ?? "");
  922. setVideoModalIds(ids);
  923. setVideoModalRaw(raw);
  924. setVideoModalOpen(true);
  925. }}
  926. >
  927. {label}
  928. </Typography.Link>
  929. );
  930. },
  931. };
  932. const videoCountColumn: ColumnsType<ElementDemandItem>[number] = {
  933. title: "视频数",
  934. dataIndex: "video_count",
  935. width: 100,
  936. render: (v) => v ?? "-",
  937. };
  938. if (mode === "monthly") {
  939. baseColumns.push({
  940. title: "频次",
  941. dataIndex: "frequency",
  942. width: 90,
  943. render: (v) => (v === null || v === undefined ? "-" : String(v)),
  944. });
  945. baseColumns.push(videoCountColumn);
  946. baseColumns.push({
  947. title: "月份列表",
  948. dataIndex: "month_list",
  949. width: 180,
  950. render: (_, record) => {
  951. const raw = record.month_list ?? "";
  952. const trimmed = raw.trim();
  953. if (!trimmed) {
  954. return "-";
  955. }
  956. const months = parseVideoIdsFromList(raw);
  957. return (
  958. <Typography.Link
  959. onClick={() => {
  960. setMonthModalTitleName(record.demand_name ?? "");
  961. setMonthModalMonths(months);
  962. setMonthModalRaw(raw);
  963. setMonthModalOpen(true);
  964. }}
  965. >
  966. {formatMonthListPreview(raw)}
  967. </Typography.Link>
  968. );
  969. },
  970. });
  971. } else {
  972. baseColumns.push(videoCountColumn);
  973. }
  974. baseColumns.push(videoListColumn);
  975. return baseColumns;
  976. },
  977. [mode]
  978. );
  979. return (
  980. <div className="panel-sheet">
  981. <section className="panel-section panel-section--filters">
  982. <header className="panel-section-head">
  983. <span className="panel-section-accent" aria-hidden />
  984. <Typography.Title level={5} className="panel-section-title">
  985. 筛选条件
  986. </Typography.Title>
  987. </header>
  988. <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
  989. <div className="filter-row second-row element-demand-filter-row">
  990. {mode === "period" ? (
  991. <Form.Item label={periodDaysLabel}>
  992. <InputNumber
  993. min={0}
  994. precision={0}
  995. value={periodDays}
  996. onChange={(v) => setPeriodDays(v ?? 7)}
  997. />
  998. </Form.Item>
  999. ) : null}
  1000. <Form.Item label="当日分发曝光PV限制">
  1001. <InputNumber
  1002. min={0}
  1003. precision={0}
  1004. value={viewPvCount}
  1005. onChange={(v) => setViewPvCount(v ?? 0)}
  1006. />
  1007. </Form.Item>
  1008. {mode === "monthly" ? (
  1009. <Form.Item label="月累计分发曝光PV阈值">
  1010. <InputNumber
  1011. min={0}
  1012. precision={0}
  1013. value={monthTotalPvThreshold}
  1014. onChange={(v) => setMonthTotalPvThreshold(v ?? 0)}
  1015. />
  1016. </Form.Item>
  1017. ) : null}
  1018. <Form.Item label="贡献分限制">
  1019. <InputNumber
  1020. min={0}
  1021. step={0.01}
  1022. value={minContributionScore}
  1023. onChange={(v) => setMinContributionScore(v ?? 0)}
  1024. />
  1025. </Form.Item>
  1026. <Form.Item
  1027. label={
  1028. mode === "monthly" ? "累加平均ROV限制" : "平均ROV限制"
  1029. }
  1030. >
  1031. <InputNumber
  1032. min={0}
  1033. step={0.001}
  1034. value={rovAvg}
  1035. onChange={(v) => setRovAvg(v ?? 0)}
  1036. />
  1037. </Form.Item>
  1038. {mode === "monthly" ? (
  1039. <Form.Item label="元素频次限制(有效月份数)">
  1040. <InputNumber
  1041. min={0}
  1042. precision={0}
  1043. value={minFrequency}
  1044. onChange={(v) => setMinFrequency(v ?? 0)}
  1045. />
  1046. </Form.Item>
  1047. ) : null}
  1048. <Form.Item label=" ">
  1049. <Button type="primary" htmlType="submit" loading={loading}>
  1050. 查询
  1051. </Button>
  1052. </Form.Item>
  1053. <Form.Item label=" ">
  1054. <Button type="default" onClick={resetDefaults}>
  1055. 恢复默认
  1056. </Button>
  1057. </Form.Item>
  1058. </div>
  1059. </Form>
  1060. {error ? (
  1061. <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
  1062. ) : null}
  1063. </section>
  1064. <section className="panel-section panel-section--table">
  1065. <div className="table-toolbar">
  1066. <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
  1067. {tableDetailTitle}
  1068. </Typography.Title>
  1069. <Space size={8} wrap className="table-toolbar-meta">
  1070. <span className="meta-chip">本地 {total} 条</span>
  1071. <span className="meta-chip">
  1072. 第 {page} / {totalPages} 页
  1073. </span>
  1074. <Button
  1075. type="default"
  1076. loading={exporting}
  1077. disabled={loading}
  1078. onClick={() => void handleExport()}
  1079. >
  1080. 导出 Excel
  1081. </Button>
  1082. </Space>
  1083. </div>
  1084. {loading && !hasLoaded ? (
  1085. <Skeleton active paragraph={{ rows: 10 }} />
  1086. ) : (
  1087. <div className="table-wrap">
  1088. <Table
  1089. rowKey={(record) =>
  1090. String(record.demand_id ?? "").trim() ||
  1091. `${record.strategy ?? ""}|${record.demand_name ?? ""}`
  1092. }
  1093. loading={loading}
  1094. columns={columns}
  1095. dataSource={pagedItems}
  1096. pagination={false}
  1097. scroll={{ x: mode === "monthly" ? 1150 : 880 }}
  1098. rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
  1099. />
  1100. </div>
  1101. )}
  1102. <Modal
  1103. title={`视频列表${videoModalTitleName ? ` — ${videoModalTitleName}` : ""}`}
  1104. open={videoModalOpen}
  1105. onCancel={() => setVideoModalOpen(false)}
  1106. footer={
  1107. <Button type="primary" onClick={() => setVideoModalOpen(false)}>
  1108. 关闭
  1109. </Button>
  1110. }
  1111. width={900}
  1112. destroyOnHidden
  1113. >
  1114. {videoModalIds.length > 0 ? (
  1115. <List
  1116. size="small"
  1117. bordered
  1118. dataSource={videoModalIds}
  1119. style={{ maxHeight: 480, overflow: "auto" }}
  1120. renderItem={(vid, idx) => (
  1121. <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
  1122. <div
  1123. style={{
  1124. display: "flex",
  1125. alignItems: "center",
  1126. width: "100%",
  1127. flexWrap: "nowrap",
  1128. gap: 12,
  1129. }}
  1130. >
  1131. <span
  1132. style={{
  1133. display: "inline-flex",
  1134. alignItems: "baseline",
  1135. gap: "0.25em",
  1136. whiteSpace: "nowrap",
  1137. minWidth: 0,
  1138. }}
  1139. >
  1140. <Typography.Text type="secondary" style={{ margin: 0 }}>
  1141. {idx + 1}.
  1142. </Typography.Text>
  1143. <Typography.Text copyable={{ text: vid }} style={{ margin: 0 }}>
  1144. {vid}
  1145. </Typography.Text>
  1146. </span>
  1147. <Space size={4} style={{ marginLeft: "auto", flexShrink: 0 }}>
  1148. <Button
  1149. type="link"
  1150. size="small"
  1151. onClick={() => openVideoCmsDetail(vid)}
  1152. >
  1153. 查看视频详情
  1154. </Button>
  1155. <Button
  1156. type="link"
  1157. size="small"
  1158. loading={decodeLoadingVid === vid}
  1159. onClick={() => void openVideoDecodePage(vid)}
  1160. >
  1161. 查看视频解构
  1162. </Button>
  1163. </Space>
  1164. </div>
  1165. </List.Item>
  1166. )}
  1167. />
  1168. ) : (
  1169. <Typography.Paragraph
  1170. copyable={{ text: videoModalRaw }}
  1171. style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
  1172. >
  1173. {videoModalRaw || "(空)"}
  1174. </Typography.Paragraph>
  1175. )}
  1176. </Modal>
  1177. <Modal
  1178. title={`月份列表${monthModalTitleName ? ` — ${monthModalTitleName}` : ""}`}
  1179. open={monthModalOpen}
  1180. onCancel={() => setMonthModalOpen(false)}
  1181. footer={
  1182. <Button type="primary" onClick={() => setMonthModalOpen(false)}>
  1183. 关闭
  1184. </Button>
  1185. }
  1186. width={560}
  1187. destroyOnHidden
  1188. >
  1189. {monthModalMonths.length > 0 ? (
  1190. <List
  1191. size="small"
  1192. bordered
  1193. dataSource={monthModalMonths}
  1194. style={{ maxHeight: 480, overflow: "auto" }}
  1195. renderItem={(ym, idx) => (
  1196. <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
  1197. <Typography.Text copyable={{ text: ym }}>
  1198. {idx + 1}. {formatYmDisplay(ym)}
  1199. </Typography.Text>
  1200. </List.Item>
  1201. )}
  1202. />
  1203. ) : (
  1204. <Typography.Paragraph
  1205. copyable={{ text: monthModalRaw }}
  1206. style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
  1207. >
  1208. {monthModalRaw || "(空)"}
  1209. </Typography.Paragraph>
  1210. )}
  1211. </Modal>
  1212. <div className="panel-footer">
  1213. <Pagination
  1214. current={page}
  1215. total={total}
  1216. pageSize={pageSize}
  1217. showSizeChanger
  1218. pageSizeOptions={["10", "20", "50", "100"]}
  1219. showQuickJumper
  1220. showTotal={(t) => `共 ${t} 条`}
  1221. onChange={(nextPage, size) => {
  1222. const nextSize = size ?? pageSize;
  1223. if (nextSize !== pageSize) {
  1224. setPageSize(nextSize);
  1225. setPage(1);
  1226. return;
  1227. }
  1228. setPage(nextPage);
  1229. }}
  1230. onShowSizeChange={(_, size) => {
  1231. setPageSize(size);
  1232. setPage(1);
  1233. }}
  1234. />
  1235. </div>
  1236. </section>
  1237. </div>
  1238. );
  1239. }
  1240. type HotContentDemandExportItem = {
  1241. id: number;
  1242. source: string;
  1243. hot_title: string;
  1244. item_text: string;
  1245. point_category: string;
  1246. item_type: string;
  1247. item_type_label: string;
  1248. matched_demand: string;
  1249. is_as_demand: number;
  1250. is_as_demand_label: string;
  1251. contribution_score: number | null;
  1252. wxindex_keyword: string;
  1253. all_hot_keywords: string;
  1254. wxindex_latest_score: number;
  1255. wxindex_trend: string;
  1256. event_sense_score: number | null;
  1257. senior_fit_score: number | null;
  1258. record_created_at: string;
  1259. };
  1260. type HotContentDemandExportResponse = {
  1261. total: number;
  1262. page: number;
  1263. page_size: number;
  1264. items: HotContentDemandExportItem[];
  1265. };
  1266. type IsAsDemandFilter = "all" | "yes" | "no";
  1267. type MatchedDemandFilter = "all" | "yes" | "no";
  1268. type ItemTypeFilter = "all" | "word" | "point";
  1269. function HotContentDemandExportPanel({ active }: { active: boolean }) {
  1270. const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
  1271. const today = dayjs();
  1272. return [today, today];
  1273. });
  1274. const [isAsDemandFilter, setIsAsDemandFilter] = useState<IsAsDemandFilter>("all");
  1275. const [matchedDemandFilter, setMatchedDemandFilter] = useState<MatchedDemandFilter>("all");
  1276. const [itemTypeFilter, setItemTypeFilter] = useState<ItemTypeFilter>("all");
  1277. const [appliedIsAsDemand, setAppliedIsAsDemand] = useState<IsAsDemandFilter>("all");
  1278. const [appliedMatchedDemand, setAppliedMatchedDemand] = useState<MatchedDemandFilter>("all");
  1279. const [appliedItemType, setAppliedItemType] = useState<ItemTypeFilter>("all");
  1280. const [minWxindexInput, setMinWxindexInput] = useState<number | null>(null);
  1281. const [appliedMinWxindex, setAppliedMinWxindex] = useState<number | null>(null);
  1282. const [minEventSenseInput, setMinEventSenseInput] = useState<number | null>(null);
  1283. const [appliedMinEventSense, setAppliedMinEventSense] = useState<number | null>(null);
  1284. const [minSeniorFitInput, setMinSeniorFitInput] = useState<number | null>(null);
  1285. const [appliedMinSeniorFit, setAppliedMinSeniorFit] = useState<number | null>(null);
  1286. const [currentPage, setCurrentPage] = useState(1);
  1287. const [pageSize, setPageSize] = useState(20);
  1288. const [refreshTick, setRefreshTick] = useState(0);
  1289. const [loading, setLoading] = useState(false);
  1290. const [exporting, setExporting] = useState(false);
  1291. const [hasLoaded, setHasLoaded] = useState(false);
  1292. const [error, setError] = useState("");
  1293. const [data, setData] = useState<HotContentDemandExportResponse>({
  1294. total: 0,
  1295. page: 1,
  1296. page_size: pageSize,
  1297. items: [],
  1298. });
  1299. const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? "";
  1300. const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? "";
  1301. const dateRangeInvalid =
  1302. Boolean(startDate) && Boolean(endDate) && startDate > endDate;
  1303. const appendFiltersToUrl = (url: URL) => {
  1304. if (startDate) {
  1305. url.searchParams.set("start_dt", startDate);
  1306. }
  1307. if (endDate) {
  1308. url.searchParams.set("end_dt", endDate);
  1309. }
  1310. if (appliedIsAsDemand === "yes") {
  1311. url.searchParams.set("is_as_demand", "1");
  1312. } else if (appliedIsAsDemand === "no") {
  1313. url.searchParams.set("is_as_demand", "0");
  1314. }
  1315. if (appliedMatchedDemand === "yes") {
  1316. url.searchParams.set("has_matched_demand", "1");
  1317. } else if (appliedMatchedDemand === "no") {
  1318. url.searchParams.set("has_matched_demand", "0");
  1319. }
  1320. if (appliedItemType === "word") {
  1321. url.searchParams.set("item_type", "词");
  1322. } else if (appliedItemType === "point") {
  1323. url.searchParams.set("item_type", "点");
  1324. }
  1325. if (appliedMinWxindex !== null) {
  1326. url.searchParams.set("min_wxindex_latest_score", String(appliedMinWxindex));
  1327. }
  1328. if (appliedMinEventSense !== null) {
  1329. url.searchParams.set("min_event_sense_score", String(appliedMinEventSense));
  1330. }
  1331. if (appliedMinSeniorFit !== null) {
  1332. url.searchParams.set("min_senior_fit_score", String(appliedMinSeniorFit));
  1333. }
  1334. };
  1335. const buildRequestUrl = (page: number, size: number = pageSize) => {
  1336. const resolvedBase = getResolvedApiBaseUrl();
  1337. const baseWithSlash = resolvedBase.endsWith("/")
  1338. ? resolvedBase
  1339. : `${resolvedBase}/`;
  1340. const url = new URL("hot-content/demand-exports", baseWithSlash);
  1341. appendFiltersToUrl(url);
  1342. url.searchParams.set("page", String(page));
  1343. url.searchParams.set("page_size", String(size));
  1344. return url.toString();
  1345. };
  1346. const buildExportUrl = () => {
  1347. const resolvedBase = getResolvedApiBaseUrl();
  1348. const baseWithSlash = resolvedBase.endsWith("/")
  1349. ? resolvedBase
  1350. : `${resolvedBase}/`;
  1351. const url = new URL("hot-content/demand-exports/export", baseWithSlash);
  1352. appendFiltersToUrl(url);
  1353. return url.toString();
  1354. };
  1355. const queryKey = JSON.stringify({
  1356. startDate,
  1357. endDate,
  1358. appliedIsAsDemand,
  1359. appliedMatchedDemand,
  1360. appliedItemType,
  1361. appliedMinWxindex,
  1362. appliedMinEventSense,
  1363. appliedMinSeniorFit,
  1364. currentPage,
  1365. pageSize,
  1366. refreshTick,
  1367. active,
  1368. });
  1369. const fetchData = async (page: number, size: number = pageSize) => {
  1370. setLoading(true);
  1371. setError("");
  1372. try {
  1373. const response = await fetch(buildRequestUrl(page, size), {
  1374. method: "GET",
  1375. headers: { Accept: "application/json" },
  1376. });
  1377. if (!response.ok) {
  1378. const detail = await response.text();
  1379. throw new Error(detail || `HTTP ${response.status}`);
  1380. }
  1381. const payload = (await response.json()) as HotContentDemandExportResponse;
  1382. setData(payload);
  1383. } catch (queryError) {
  1384. setError(
  1385. queryError instanceof Error ? queryError.message : "查询失败,请重试",
  1386. );
  1387. } finally {
  1388. setLoading(false);
  1389. setHasLoaded(true);
  1390. }
  1391. };
  1392. useEffect(() => {
  1393. if (!active || dateRangeInvalid) {
  1394. return;
  1395. }
  1396. void fetchData(currentPage, pageSize);
  1397. }, [queryKey, dateRangeInvalid]);
  1398. const handleSubmit = () => {
  1399. if (dateRangeInvalid) {
  1400. return;
  1401. }
  1402. setAppliedIsAsDemand(isAsDemandFilter);
  1403. setAppliedMatchedDemand(matchedDemandFilter);
  1404. setAppliedItemType(itemTypeFilter);
  1405. setAppliedMinWxindex(minWxindexInput);
  1406. setAppliedMinEventSense(minEventSenseInput);
  1407. setAppliedMinSeniorFit(minSeniorFitInput);
  1408. setCurrentPage(1);
  1409. setRefreshTick((value) => value + 1);
  1410. };
  1411. const handleExport = async () => {
  1412. if (dateRangeInvalid) {
  1413. return;
  1414. }
  1415. setExporting(true);
  1416. try {
  1417. await downloadExcelExport(buildExportUrl(), "新热事件查询.xlsx");
  1418. message.success("导出成功");
  1419. } catch {
  1420. message.error("导出失败,请重试");
  1421. } finally {
  1422. setExporting(false);
  1423. }
  1424. };
  1425. const resetFilters = () => {
  1426. const today = dayjs();
  1427. setDateRange([today, today]);
  1428. setIsAsDemandFilter("all");
  1429. setMatchedDemandFilter("all");
  1430. setItemTypeFilter("all");
  1431. setAppliedIsAsDemand("all");
  1432. setAppliedMatchedDemand("all");
  1433. setAppliedItemType("all");
  1434. setMinWxindexInput(null);
  1435. setAppliedMinWxindex(null);
  1436. setMinEventSenseInput(null);
  1437. setAppliedMinEventSense(null);
  1438. setMinSeniorFitInput(null);
  1439. setAppliedMinSeniorFit(null);
  1440. setCurrentPage(1);
  1441. setRefreshTick((value) => value + 1);
  1442. };
  1443. const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
  1444. const columns: ColumnsType<HotContentDemandExportItem> = useMemo(
  1445. () => [
  1446. {
  1447. title: "来源",
  1448. dataIndex: "source",
  1449. width: 100,
  1450. ellipsis: true,
  1451. fixed: "left",
  1452. render: (v: string) => <EllipsisCell value={v} />,
  1453. },
  1454. {
  1455. title: "热点标题",
  1456. dataIndex: "hot_title",
  1457. width: 220,
  1458. ellipsis: true,
  1459. fixed: "left",
  1460. render: (v: string) => <EllipsisCell value={v} />,
  1461. },
  1462. {
  1463. title: "词条",
  1464. dataIndex: "item_text",
  1465. width: 160,
  1466. ellipsis: true,
  1467. fixed: "left",
  1468. render: (v: string) => <EllipsisCell value={v} />,
  1469. },
  1470. {
  1471. title: "类型",
  1472. dataIndex: "point_category",
  1473. width: 100,
  1474. fixed: "left",
  1475. render: (v: string) => <EllipsisCell value={v} />,
  1476. },
  1477. {
  1478. title: "需求类型",
  1479. dataIndex: "item_type_label",
  1480. width: 90,
  1481. align: "center",
  1482. fixed: "left",
  1483. },
  1484. {
  1485. title: "是否成为需求",
  1486. dataIndex: "is_as_demand_label",
  1487. width: 120,
  1488. align: "center",
  1489. fixed: "left",
  1490. },
  1491. {
  1492. title: "匹配需求",
  1493. dataIndex: "matched_demand",
  1494. width: 200,
  1495. ellipsis: true,
  1496. render: (v: string) => <EllipsisCell value={v} />,
  1497. },
  1498. {
  1499. title: "创建时间",
  1500. dataIndex: "record_created_at",
  1501. width: 170,
  1502. },
  1503. {
  1504. title: "贡献分",
  1505. dataIndex: "contribution_score",
  1506. width: 100,
  1507. align: "right",
  1508. render: (v: number | null) =>
  1509. v === null || v === undefined ? "-" : Number(v).toFixed(2),
  1510. },
  1511. {
  1512. title: "最高微信指数词",
  1513. dataIndex: "wxindex_keyword",
  1514. width: 160,
  1515. ellipsis: true,
  1516. render: (v: string) => <EllipsisCell value={v} />,
  1517. },
  1518. {
  1519. title: "待选微信指数词",
  1520. dataIndex: "all_hot_keywords",
  1521. width: 200,
  1522. ellipsis: true,
  1523. render: (v: string) => <EllipsisCell value={v} />,
  1524. },
  1525. {
  1526. title: "微信指数热度",
  1527. dataIndex: "wxindex_latest_score",
  1528. width: 120,
  1529. align: "right",
  1530. render: (v: number) => Number(v ?? 0).toLocaleString(),
  1531. },
  1532. { title: "微信指数趋势", dataIndex: "wxindex_trend", width: 110, render: (v: string) => <EllipsisCell value={v} /> },
  1533. {
  1534. title: "事件性得分",
  1535. dataIndex: "event_sense_score",
  1536. width: 110,
  1537. align: "right",
  1538. render: (v: number | null) =>
  1539. v === null || v === undefined ? "-" : Number(v).toFixed(2),
  1540. },
  1541. {
  1542. title: "老年性得分",
  1543. dataIndex: "senior_fit_score",
  1544. width: 110,
  1545. align: "right",
  1546. render: (v: number | null) =>
  1547. v === null || v === undefined ? "-" : Number(v).toFixed(2),
  1548. },
  1549. ],
  1550. [],
  1551. );
  1552. return (
  1553. <div className="panel-sheet">
  1554. <section className="panel-section panel-section--filters">
  1555. <header className="panel-section-head">
  1556. <span className="panel-section-accent" aria-hidden />
  1557. <Typography.Title level={5} className="panel-section-title">
  1558. 筛选条件
  1559. </Typography.Title>
  1560. </header>
  1561. <Form layout="vertical" onFinish={handleSubmit} className="filter-form">
  1562. <div className="filter-row second-row hot-content-demand-filter-row">
  1563. <Form.Item label="日期区间(热点记录创建时间)">
  1564. <DatePicker.RangePicker
  1565. value={dateRange}
  1566. onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
  1567. allowClear={false}
  1568. />
  1569. </Form.Item>
  1570. <Form.Item label="是否是需求">
  1571. <Select<IsAsDemandFilter>
  1572. className="hot-content-filter-select"
  1573. value={isAsDemandFilter}
  1574. onChange={setIsAsDemandFilter}
  1575. options={[
  1576. { label: "全部", value: "all" },
  1577. { label: "是", value: "yes" },
  1578. { label: "否", value: "no" },
  1579. ]}
  1580. />
  1581. </Form.Item>
  1582. <Form.Item label="需求类型">
  1583. <Select<ItemTypeFilter>
  1584. className="hot-content-filter-select"
  1585. value={itemTypeFilter}
  1586. onChange={setItemTypeFilter}
  1587. options={[
  1588. { label: "全部", value: "all" },
  1589. { label: "特征点", value: "word" },
  1590. { label: "短语", value: "point" },
  1591. ]}
  1592. />
  1593. </Form.Item>
  1594. <Form.Item label="是否匹配需求">
  1595. <Select<MatchedDemandFilter>
  1596. className="hot-content-filter-select"
  1597. value={matchedDemandFilter}
  1598. onChange={setMatchedDemandFilter}
  1599. options={[
  1600. { label: "全部", value: "all" },
  1601. { label: "是", value: "yes" },
  1602. { label: "否", value: "no" },
  1603. ]}
  1604. />
  1605. </Form.Item>
  1606. <Form.Item label="微信指数热度 ≥">
  1607. <InputNumber
  1608. className="hot-content-wxindex-input"
  1609. min={0}
  1610. step={10000}
  1611. placeholder="不限制"
  1612. value={minWxindexInput}
  1613. onChange={(v) => setMinWxindexInput(v ?? null)}
  1614. />
  1615. </Form.Item>
  1616. <Form.Item label="事件性得分 ≥">
  1617. <InputNumber
  1618. className="hot-content-wxindex-input"
  1619. min={0}
  1620. max={10}
  1621. step={0.1}
  1622. placeholder="不限制"
  1623. value={minEventSenseInput}
  1624. onChange={(v) => setMinEventSenseInput(v ?? null)}
  1625. />
  1626. </Form.Item>
  1627. <Form.Item label="老年性得分 ≥">
  1628. <InputNumber
  1629. className="hot-content-wxindex-input"
  1630. min={0}
  1631. max={10}
  1632. step={0.1}
  1633. placeholder="不限制"
  1634. value={minSeniorFitInput}
  1635. onChange={(v) => setMinSeniorFitInput(v ?? null)}
  1636. />
  1637. </Form.Item>
  1638. <Form.Item label=" ">
  1639. <Button
  1640. type="primary"
  1641. htmlType="submit"
  1642. loading={loading}
  1643. disabled={dateRangeInvalid}
  1644. >
  1645. 查询
  1646. </Button>
  1647. </Form.Item>
  1648. <Form.Item label=" ">
  1649. <Button type="default" onClick={resetFilters}>
  1650. 重置
  1651. </Button>
  1652. </Form.Item>
  1653. </div>
  1654. </Form>
  1655. {dateRangeInvalid ? (
  1656. <Alert
  1657. style={{ marginTop: 12 }}
  1658. type="error"
  1659. showIcon
  1660. message="开始日期不能晚于结束日期"
  1661. />
  1662. ) : null}
  1663. {error ? (
  1664. <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
  1665. ) : null}
  1666. </section>
  1667. <section className="panel-section panel-section--table">
  1668. <div className="table-toolbar">
  1669. <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
  1670. 新热事件明细
  1671. </Typography.Title>
  1672. <Space size={8} wrap className="table-toolbar-meta">
  1673. <span className="meta-chip">共 {data.total} 条</span>
  1674. <span className="meta-chip">
  1675. 第 {currentPage} / {totalPages} 页
  1676. </span>
  1677. <Button
  1678. type="default"
  1679. loading={exporting}
  1680. disabled={dateRangeInvalid || loading}
  1681. onClick={() => void handleExport()}
  1682. >
  1683. 导出 Excel
  1684. </Button>
  1685. </Space>
  1686. </div>
  1687. {loading && !hasLoaded ? (
  1688. <Skeleton active paragraph={{ rows: 10 }} />
  1689. ) : (
  1690. <div className="table-wrap">
  1691. <Table
  1692. rowKey="id"
  1693. loading={loading}
  1694. columns={columns}
  1695. dataSource={data.items}
  1696. pagination={false}
  1697. scroll={{ x: 2100 }}
  1698. rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
  1699. />
  1700. </div>
  1701. )}
  1702. <div className="panel-footer">
  1703. <Pagination
  1704. current={currentPage}
  1705. total={data.total}
  1706. pageSize={pageSize}
  1707. showSizeChanger
  1708. pageSizeOptions={["10", "20", "50", "100"]}
  1709. showQuickJumper
  1710. showTotal={(total) => `共 ${total} 条`}
  1711. onChange={(page, size) => {
  1712. const nextSize = size ?? pageSize;
  1713. setCurrentPage(page);
  1714. if (nextSize !== pageSize) {
  1715. setPageSize(nextSize);
  1716. }
  1717. }}
  1718. onShowSizeChange={(_, size) => {
  1719. setPageSize(size);
  1720. setCurrentPage(1);
  1721. }}
  1722. />
  1723. </div>
  1724. </section>
  1725. </div>
  1726. );
  1727. }
  1728. function SolarCalendarPanel({ active }: { active: boolean }) {
  1729. return (
  1730. <ElementDemandQueryPanel
  1731. active={active}
  1732. apiPath="element-demands/solar-calendar"
  1733. periodDaysLabel="区间天数(含去年阳历今日)"
  1734. tableDetailTitle="去年同期阳历特征点明细"
  1735. />
  1736. );
  1737. }
  1738. function LunarCalendarPanel({ active }: { active: boolean }) {
  1739. return (
  1740. <ElementDemandQueryPanel
  1741. active={active}
  1742. apiPath="element-demands/lunar-calendar"
  1743. periodDaysLabel="区间天数(含去年阴历今日)"
  1744. tableDetailTitle="去年同期阴历特征点明细"
  1745. />
  1746. );
  1747. }
  1748. function MonthlyDemandPanel({ active }: { active: boolean }) {
  1749. return (
  1750. <ElementDemandQueryPanel
  1751. active={active}
  1752. apiPath="element-demands/monthly"
  1753. periodDaysLabel=""
  1754. tableDetailTitle="逐月特征点明细"
  1755. mode="monthly"
  1756. />
  1757. );
  1758. }
  1759. function App() {
  1760. const [activeTab, setActiveTab] = useState("demand-pool");
  1761. const [hotSourceView, setHotSourceView] = useState<HotSourceViewParams | null>(() =>
  1762. readHotSourceViewFromUrl(),
  1763. );
  1764. useEffect(() => {
  1765. const onPopState = () => {
  1766. setHotSourceView(readHotSourceViewFromUrl());
  1767. };
  1768. window.addEventListener("popstate", onPopState);
  1769. return () => window.removeEventListener("popstate", onPopState);
  1770. }, []);
  1771. const tabItems = useMemo(
  1772. () => [
  1773. {
  1774. key: "demand-pool",
  1775. label: "需求池",
  1776. children: <DemandPoolPanel />,
  1777. },
  1778. {
  1779. key: "solar-calendar",
  1780. label: "去年同期阳历特征点查询",
  1781. children: (
  1782. <SolarCalendarPanel active={activeTab === "solar-calendar"} />
  1783. ),
  1784. },
  1785. {
  1786. key: "lunar-calendar",
  1787. label: "去年同期阴历特征点查询",
  1788. children: (
  1789. <LunarCalendarPanel active={activeTab === "lunar-calendar"} />
  1790. ),
  1791. },
  1792. {
  1793. key: "monthly-demand",
  1794. label: "逐月特征点查询",
  1795. children: (
  1796. <MonthlyDemandPanel active={activeTab === "monthly-demand"} />
  1797. ),
  1798. },
  1799. {
  1800. key: "hot-content-demand",
  1801. label: "新热事件查询",
  1802. children: (
  1803. <HotContentDemandExportPanel active={activeTab === "hot-content-demand"} />
  1804. ),
  1805. },
  1806. ],
  1807. [activeTab],
  1808. );
  1809. if (hotSourceView) {
  1810. return (
  1811. <HotContentSourcePage
  1812. demandName={hotSourceView.demandName}
  1813. demandType={hotSourceView.demandType}
  1814. dt={hotSourceView.dt}
  1815. onBack={closeHotSourceView}
  1816. />
  1817. );
  1818. }
  1819. return (
  1820. <>
  1821. <DemandNavBar active="dashboard" />
  1822. <div className="page">
  1823. <div className="hero">
  1824. <Typography.Title level={2} className="hero-title">
  1825. 需求池数据看板
  1826. </Typography.Title>
  1827. <div className="hero-subtitle">
  1828. <Tag color="blue">数据检索</Tag>
  1829. <Tag color="cyan">策略筛选</Tag>
  1830. <Tag color="geekblue">日期范围分析</Tag>
  1831. <Tag color="purple">需求筛选</Tag>
  1832. </div>
  1833. </div>
  1834. <div className="dashboard-shell">
  1835. <Tabs
  1836. className="main-tabs demand-nav-tabs"
  1837. activeKey={activeTab}
  1838. onChange={setActiveTab}
  1839. tabBarGutter={16}
  1840. items={tabItems}
  1841. />
  1842. </div>
  1843. </div>
  1844. </>
  1845. );
  1846. }
  1847. export default App;