App.tsx 55 KB

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