App.tsx 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311
  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. type DemandPoolItem = {
  26. id: number;
  27. strategy: string | null;
  28. demand_name: string | null;
  29. type: string | null;
  30. weight: number | null;
  31. video_count: number | null;
  32. dt: string | null;
  33. };
  34. type QueryResponse = {
  35. total: number;
  36. page: number;
  37. page_size: number;
  38. items: DemandPoolItem[];
  39. };
  40. type StrategyOption = {
  41. strategy: string;
  42. record_count: number;
  43. };
  44. type StrategyResponse = {
  45. items: StrategyOption[];
  46. };
  47. type ElementDemandItem = {
  48. strategy: string | null;
  49. demand_id: string | null;
  50. demand_name: string | null;
  51. weight: number | null;
  52. video_count: number | null;
  53. video_list: string | null;
  54. month_list?: string | null;
  55. frequency?: number | null;
  56. ext_info: string | null;
  57. };
  58. type ElementDemandResponse = {
  59. items: ElementDemandItem[];
  60. };
  61. function parseVideoIdsFromList(raw: string | null): string[] {
  62. const t = (raw ?? "").trim();
  63. if (!t) {
  64. return [];
  65. }
  66. try {
  67. const parsed: unknown = JSON.parse(t);
  68. if (Array.isArray(parsed)) {
  69. return parsed.map((item) => String(item));
  70. }
  71. } catch {
  72. /* 非 JSON 数组则走下方原始文本展示 */
  73. }
  74. return [];
  75. }
  76. function formatYmDisplay(ym: string): string {
  77. if (/^\d{6}$/.test(ym)) {
  78. return `${ym.slice(0, 4)}-${ym.slice(4, 6)}`;
  79. }
  80. return ym;
  81. }
  82. function formatMonthListPreview(raw: string | null): string {
  83. const months = parseVideoIdsFromList(raw);
  84. if (months.length > 0) {
  85. return `共 ${months.length} 个月,点击查看`;
  86. }
  87. const trimmed = (raw ?? "").trim();
  88. if (!trimmed) {
  89. return "-";
  90. }
  91. return trimmed.length > 36 ? `${trimmed.slice(0, 36)}…` : trimmed;
  92. }
  93. const API_BASE_URL =
  94. import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
  95. const getResolvedApiBaseUrl = () => {
  96. if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
  97. return API_BASE_URL;
  98. }
  99. return new URL(API_BASE_URL, window.location.origin).toString();
  100. };
  101. function parseFilenameFromContentDisposition(header: string | null): string | null {
  102. if (!header) {
  103. return null;
  104. }
  105. const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
  106. if (utf8Match?.[1]) {
  107. try {
  108. return decodeURIComponent(utf8Match[1]);
  109. } catch {
  110. return utf8Match[1];
  111. }
  112. }
  113. const asciiMatch = header.match(/filename="([^"]+)"/i);
  114. return asciiMatch?.[1] ?? null;
  115. }
  116. async function downloadExcelExport(url: string, defaultFilename: string) {
  117. const response = await fetch(url, { method: "GET" });
  118. if (!response.ok) {
  119. throw new Error(`HTTP ${response.status}`);
  120. }
  121. const blob = await response.blob();
  122. const filename =
  123. parseFilenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
  124. defaultFilename;
  125. const objectUrl = URL.createObjectURL(blob);
  126. const link = document.createElement("a");
  127. link.href = objectUrl;
  128. link.download = filename;
  129. link.click();
  130. URL.revokeObjectURL(objectUrl);
  131. }
  132. /** 票圈后台:视频详情页(新标签打开) */
  133. const CMS_VIDEO_POST_DETAIL_BASE =
  134. "https://admin.piaoquantv.com/cms/post-detail/";
  135. function DemandPoolPanel() {
  136. const [strategies, setStrategies] = useState<string[]>([]);
  137. const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
  138. const today = dayjs();
  139. return [today, today];
  140. });
  141. const [strategyOptions, setStrategyOptions] = useState<StrategyOption[]>([]);
  142. const [currentPage, setCurrentPage] = useState(1);
  143. const [pageSize, setPageSize] = useState(20);
  144. const [refreshTick, setRefreshTick] = useState(0);
  145. const [minWeightInput, setMinWeightInput] = useState<number | null>(null);
  146. const [maxWeightInput, setMaxWeightInput] = useState<number | null>(null);
  147. const [appliedMinWeight, setAppliedMinWeight] = useState<number | null>(null);
  148. const [appliedMaxWeight, setAppliedMaxWeight] = useState<number | null>(null);
  149. const [demandNameInput, setDemandNameInput] = useState("");
  150. const [appliedDemandName, setAppliedDemandName] = useState("");
  151. const [sortBy, setSortBy] = useState("weight");
  152. const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
  153. const [loading, setLoading] = useState(false);
  154. const [exporting, setExporting] = useState(false);
  155. const [hasLoaded, setHasLoaded] = useState(false);
  156. const [loadingStrategies, setLoadingStrategies] = useState(false);
  157. const [error, setError] = useState("");
  158. const [data, setData] = useState<QueryResponse>({
  159. total: 0,
  160. page: 1,
  161. page_size: pageSize,
  162. items: []
  163. });
  164. const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? "";
  165. const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? "";
  166. const dateRangeInvalid =
  167. Boolean(startDate) && Boolean(endDate) && startDate > endDate;
  168. const weightRangeInvalid =
  169. minWeightInput !== null && maxWeightInput !== null && minWeightInput > maxWeightInput;
  170. const queryKey = JSON.stringify({
  171. strategies,
  172. startDate,
  173. endDate,
  174. appliedMinWeight,
  175. appliedMaxWeight,
  176. appliedDemandName,
  177. sortBy,
  178. sortOrder,
  179. currentPage,
  180. pageSize,
  181. refreshTick,
  182. });
  183. const buildRequestUrl = (page: number, size: number = pageSize) => {
  184. const resolvedBase = getResolvedApiBaseUrl();
  185. const baseWithSlash = resolvedBase.endsWith("/")
  186. ? resolvedBase
  187. : `${resolvedBase}/`;
  188. const url = new URL("demand-pool", baseWithSlash);
  189. for (const strategyValue of strategies) {
  190. url.searchParams.append("strategy", strategyValue);
  191. }
  192. if (startDate) {
  193. url.searchParams.set("start_dt", startDate);
  194. }
  195. if (endDate) {
  196. url.searchParams.set("end_dt", endDate);
  197. }
  198. if (appliedMinWeight !== null) {
  199. url.searchParams.set("min_weight", String(appliedMinWeight));
  200. }
  201. if (appliedMaxWeight !== null) {
  202. url.searchParams.set("max_weight", String(appliedMaxWeight));
  203. }
  204. const trimmedDemandName = appliedDemandName.trim();
  205. if (trimmedDemandName) {
  206. url.searchParams.set("demand_name", trimmedDemandName);
  207. }
  208. url.searchParams.set("sort_by", sortBy);
  209. url.searchParams.set("sort_order", sortOrder);
  210. url.searchParams.set("page", String(page));
  211. url.searchParams.set("page_size", String(size));
  212. return url.toString();
  213. };
  214. const buildExportUrl = () => {
  215. const resolvedBase = getResolvedApiBaseUrl();
  216. const baseWithSlash = resolvedBase.endsWith("/")
  217. ? resolvedBase
  218. : `${resolvedBase}/`;
  219. const url = new URL("demand-pool/export", baseWithSlash);
  220. for (const strategyValue of strategies) {
  221. url.searchParams.append("strategy", strategyValue);
  222. }
  223. if (startDate) {
  224. url.searchParams.set("start_dt", startDate);
  225. }
  226. if (endDate) {
  227. url.searchParams.set("end_dt", endDate);
  228. }
  229. if (appliedMinWeight !== null) {
  230. url.searchParams.set("min_weight", String(appliedMinWeight));
  231. }
  232. if (appliedMaxWeight !== null) {
  233. url.searchParams.set("max_weight", String(appliedMaxWeight));
  234. }
  235. const trimmedDemandName = appliedDemandName.trim();
  236. if (trimmedDemandName) {
  237. url.searchParams.set("demand_name", trimmedDemandName);
  238. }
  239. url.searchParams.set("sort_by", sortBy);
  240. url.searchParams.set("sort_order", sortOrder);
  241. return url.toString();
  242. };
  243. const handleExport = async () => {
  244. if (dateRangeInvalid || weightRangeInvalid) {
  245. return;
  246. }
  247. setExporting(true);
  248. try {
  249. await downloadExcelExport(buildExportUrl(), "需求池.xlsx");
  250. message.success("导出成功");
  251. } catch {
  252. message.error("导出失败,请重试");
  253. } finally {
  254. setExporting(false);
  255. }
  256. };
  257. const buildStrategyUrl = () => {
  258. const resolvedBase = getResolvedApiBaseUrl();
  259. const baseWithSlash = resolvedBase.endsWith("/")
  260. ? resolvedBase
  261. : `${resolvedBase}/`;
  262. const url = new URL("demand-pool/strategies", baseWithSlash);
  263. if (startDate) {
  264. url.searchParams.set("start_dt", startDate);
  265. }
  266. if (endDate) {
  267. url.searchParams.set("end_dt", endDate);
  268. }
  269. if (appliedMinWeight !== null) {
  270. url.searchParams.set("min_weight", String(appliedMinWeight));
  271. }
  272. if (appliedMaxWeight !== null) {
  273. url.searchParams.set("max_weight", String(appliedMaxWeight));
  274. }
  275. return url.toString();
  276. };
  277. const fetchData = async (page: number, size: number = pageSize) => {
  278. setLoading(true);
  279. setError("");
  280. try {
  281. const response = await fetch(buildRequestUrl(page, size), {
  282. method: "GET",
  283. headers: { Accept: "application/json" }
  284. });
  285. if (!response.ok) {
  286. throw new Error(`HTTP ${response.status}`);
  287. }
  288. const payload = (await response.json()) as QueryResponse;
  289. setData(payload);
  290. } catch (queryError) {
  291. setError(
  292. queryError instanceof Error ? queryError.message : "查询失败,请重试"
  293. );
  294. } finally {
  295. setLoading(false);
  296. setHasLoaded(true);
  297. }
  298. };
  299. const fetchStrategies = async () => {
  300. setLoadingStrategies(true);
  301. try {
  302. const response = await fetch(buildStrategyUrl(), {
  303. method: "GET",
  304. headers: { Accept: "application/json" }
  305. });
  306. if (!response.ok) {
  307. throw new Error(`HTTP ${response.status}`);
  308. }
  309. const payload = (await response.json()) as StrategyResponse;
  310. setStrategyOptions(payload.items);
  311. const availableSet = new Set(payload.items.map((item) => item.strategy));
  312. const allStrategies = payload.items.map((item) => item.strategy);
  313. setStrategies((prev) => {
  314. if (prev.length === 0) {
  315. return allStrategies;
  316. }
  317. const filtered = prev.filter((value) => availableSet.has(value));
  318. return filtered.length > 0 ? filtered : allStrategies;
  319. });
  320. } catch {
  321. setStrategyOptions([]);
  322. } finally {
  323. setLoadingStrategies(false);
  324. }
  325. };
  326. useEffect(() => {
  327. void fetchStrategies();
  328. }, [startDate, endDate, appliedMinWeight, appliedMaxWeight]);
  329. useEffect(() => {
  330. if (dateRangeInvalid || weightRangeInvalid || loadingStrategies) {
  331. return;
  332. }
  333. if (strategyOptions.length > 0 && strategies.length === 0) {
  334. return;
  335. }
  336. void fetchData(currentPage, pageSize);
  337. }, [queryKey, loadingStrategies, dateRangeInvalid, weightRangeInvalid, strategyOptions.length]);
  338. const handleSubmit = async () => {
  339. if (dateRangeInvalid || weightRangeInvalid) {
  340. return;
  341. }
  342. setAppliedMinWeight(minWeightInput);
  343. setAppliedMaxWeight(maxWeightInput);
  344. setAppliedDemandName(demandNameInput.trim());
  345. setCurrentPage(1);
  346. setRefreshTick((value) => value + 1);
  347. };
  348. const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
  349. const handlePageSizeChange = (value: number) => {
  350. setPageSize(value);
  351. setCurrentPage(1);
  352. };
  353. const resetFilters = () => {
  354. const today = dayjs();
  355. setDateRange([today, today]);
  356. setStrategies(strategyOptions.map((item) => item.strategy));
  357. setMinWeightInput(null);
  358. setMaxWeightInput(null);
  359. setAppliedMinWeight(null);
  360. setAppliedMaxWeight(null);
  361. setDemandNameInput("");
  362. setAppliedDemandName("");
  363. setSortBy("weight");
  364. setSortOrder("desc");
  365. setCurrentPage(1);
  366. };
  367. const getSortOrderForColumn = (columnKey: string): SortOrder | null => {
  368. if (sortBy !== columnKey) {
  369. return null;
  370. }
  371. return sortOrder === "asc" ? "ascend" : "descend";
  372. };
  373. const columns: ColumnsType<DemandPoolItem> = useMemo(
  374. () => [
  375. { title: "ID", dataIndex: "id", width: 90, sorter: true, sortOrder: getSortOrderForColumn("id") },
  376. {
  377. title: "策略名",
  378. dataIndex: "strategy",
  379. render: (v) => v ?? "-",
  380. sorter: true,
  381. sortOrder: getSortOrderForColumn("strategy"),
  382. },
  383. {
  384. title: "需求名称",
  385. dataIndex: "demand_name",
  386. render: (v) => v ?? "-",
  387. sorter: true,
  388. sortOrder: getSortOrderForColumn("demand_name"),
  389. },
  390. {
  391. title: "需求类型",
  392. dataIndex: "type",
  393. width: 120,
  394. render: (v) => v ?? "-",
  395. sorter: true,
  396. sortOrder: getSortOrderForColumn("type"),
  397. },
  398. {
  399. title: "权重",
  400. dataIndex: "weight",
  401. width: 120,
  402. render: (v) => v ?? "-",
  403. sorter: true,
  404. sortOrder: getSortOrderForColumn("weight"),
  405. },
  406. {
  407. title: "视频数量",
  408. dataIndex: "video_count",
  409. width: 120,
  410. render: (v) => v ?? "-",
  411. sorter: true,
  412. sortOrder: getSortOrderForColumn("video_count"),
  413. },
  414. {
  415. title: "日期",
  416. dataIndex: "dt",
  417. width: 120,
  418. render: (v) => v ?? "-",
  419. sorter: true,
  420. sortOrder: getSortOrderForColumn("dt"),
  421. },
  422. ],
  423. [sortBy, sortOrder]
  424. );
  425. return (
  426. <div className="panel-sheet">
  427. <section className="panel-section panel-section--filters">
  428. <header className="panel-section-head">
  429. <span className="panel-section-accent" aria-hidden />
  430. <Typography.Title level={5} className="panel-section-title">
  431. 筛选条件
  432. </Typography.Title>
  433. </header>
  434. <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
  435. <div className="filter-row">
  436. <Form.Item label="策略名" className="strategy-item">
  437. <Select
  438. className="strategy-select"
  439. placeholder="请选择策略(支持多选)"
  440. value={strategies}
  441. onChange={setStrategies}
  442. loading={loadingStrategies}
  443. mode="multiple"
  444. allowClear
  445. maxTagCount="responsive"
  446. showSearch
  447. optionFilterProp="label"
  448. options={strategyOptions.map((item) => ({
  449. label: `${item.strategy} (${item.record_count})`,
  450. value: item.strategy,
  451. }))}
  452. />
  453. </Form.Item>
  454. </div>
  455. <div className="filter-row second-row">
  456. <Form.Item label="日期区间">
  457. <DatePicker.RangePicker
  458. value={dateRange}
  459. onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
  460. allowClear
  461. />
  462. </Form.Item>
  463. <Form.Item label="权重分">
  464. <Space>
  465. <InputNumber
  466. placeholder="最小值"
  467. value={minWeightInput}
  468. onChange={(value) => setMinWeightInput(value)}
  469. />
  470. <span>-</span>
  471. <InputNumber
  472. placeholder="最大值"
  473. value={maxWeightInput}
  474. onChange={(value) => setMaxWeightInput(value)}
  475. />
  476. </Space>
  477. </Form.Item>
  478. <Form.Item label="需求名称">
  479. <Input
  480. allowClear
  481. value={demandNameInput}
  482. onChange={(e) => setDemandNameInput(e.target.value)}
  483. />
  484. </Form.Item>
  485. <Form.Item label=" ">
  486. <Button
  487. type="primary"
  488. htmlType="submit"
  489. loading={loading}
  490. disabled={dateRangeInvalid || weightRangeInvalid}
  491. >
  492. 查询
  493. </Button>
  494. </Form.Item>
  495. <Form.Item label=" ">
  496. <Space>
  497. <Button type="default" onClick={resetFilters}>
  498. 重置
  499. </Button>
  500. </Space>
  501. </Form.Item>
  502. </div>
  503. </Form>
  504. {dateRangeInvalid ? (
  505. <Alert
  506. style={{ marginTop: 12 }}
  507. type="error"
  508. showIcon
  509. message="开始日期不能晚于结束日期"
  510. />
  511. ) : null}
  512. {weightRangeInvalid ? (
  513. <Alert
  514. style={{ marginTop: 12 }}
  515. type="error"
  516. showIcon
  517. message="最小权重不能大于最大权重"
  518. />
  519. ) : null}
  520. {error ? (
  521. <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
  522. ) : null}
  523. </section>
  524. <section className="panel-section panel-section--table">
  525. <div className="table-toolbar">
  526. <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
  527. 需求明细
  528. </Typography.Title>
  529. <Space size={8} wrap className="table-toolbar-meta">
  530. <span className="meta-chip">已选策略 {strategies.length}</span>
  531. <span className="meta-chip">共 {data.total} 条</span>
  532. <span className="meta-chip">
  533. 第 {currentPage} / {totalPages} 页
  534. </span>
  535. <Button
  536. type="default"
  537. loading={exporting}
  538. disabled={dateRangeInvalid || weightRangeInvalid || loading}
  539. onClick={() => void handleExport()}
  540. >
  541. 导出 Excel
  542. </Button>
  543. </Space>
  544. </div>
  545. {loading && !hasLoaded ? (
  546. <Skeleton active paragraph={{ rows: 10 }} />
  547. ) : (
  548. <div className="table-wrap">
  549. <Table
  550. rowKey="id"
  551. loading={loading}
  552. columns={columns}
  553. dataSource={data.items}
  554. pagination={false}
  555. scroll={{ x: 1060 }}
  556. rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
  557. onChange={(_, __, sorter) => {
  558. if (Array.isArray(sorter)) {
  559. return;
  560. }
  561. const nextField = typeof sorter.field === "string" ? sorter.field : null;
  562. const nextOrder = sorter.order;
  563. if (!nextField || !nextOrder) {
  564. setSortBy("weight");
  565. setSortOrder("desc");
  566. setCurrentPage(1);
  567. return;
  568. }
  569. setSortBy(nextField);
  570. setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
  571. setCurrentPage(1);
  572. }}
  573. />
  574. </div>
  575. )}
  576. <div className="panel-footer">
  577. <Pagination
  578. current={currentPage}
  579. total={data.total}
  580. pageSize={pageSize}
  581. showSizeChanger
  582. pageSizeOptions={["10", "20", "50", "100"]}
  583. showQuickJumper
  584. showTotal={(total) => `共 ${total} 条`}
  585. onChange={(page, size) => {
  586. const nextSize = size ?? pageSize;
  587. setCurrentPage(page);
  588. if (nextSize !== pageSize) {
  589. setPageSize(nextSize);
  590. return;
  591. }
  592. }}
  593. onShowSizeChange={(_, size) => {
  594. handlePageSizeChange(size);
  595. }}
  596. />
  597. </div>
  598. </section>
  599. </div>
  600. );
  601. }
  602. function ElementDemandQueryPanel({
  603. active,
  604. apiPath,
  605. periodDaysLabel,
  606. tableDetailTitle,
  607. mode = "period",
  608. }: {
  609. active: boolean;
  610. apiPath: string;
  611. periodDaysLabel: string;
  612. tableDetailTitle: string;
  613. /** period:阳历/阴历同期;monthly:逐月回溯窗口 */
  614. mode?: "period" | "monthly";
  615. }) {
  616. const [periodDays, setPeriodDays] = useState(7);
  617. const [monthTotalPvThreshold, setMonthTotalPvThreshold] = useState(20000);
  618. const [minFrequency, setMinFrequency] = useState(4);
  619. const [viewPvCount, setViewPvCount] = useState(2000);
  620. const [minContributionScore, setMinContributionScore] = useState(0.8);
  621. const [rovAvg, setRovAvg] = useState(0.04);
  622. const [items, setItems] = useState<ElementDemandItem[]>([]);
  623. const [loading, setLoading] = useState(false);
  624. const [exporting, setExporting] = useState(false);
  625. const [hasLoaded, setHasLoaded] = useState(false);
  626. const [error, setError] = useState("");
  627. const [page, setPage] = useState(1);
  628. const [pageSize, setPageSize] = useState(20);
  629. const [videoModalOpen, setVideoModalOpen] = useState(false);
  630. const [videoModalTitleName, setVideoModalTitleName] = useState("");
  631. const [videoModalIds, setVideoModalIds] = useState<string[]>([]);
  632. const [videoModalRaw, setVideoModalRaw] = useState("");
  633. const [monthModalOpen, setMonthModalOpen] = useState(false);
  634. const [monthModalTitleName, setMonthModalTitleName] = useState("");
  635. const [monthModalMonths, setMonthModalMonths] = useState<string[]>([]);
  636. const [monthModalRaw, setMonthModalRaw] = useState("");
  637. const [decodeLoadingVid, setDecodeLoadingVid] = useState<string | null>(null);
  638. const openVideoCmsDetail = useCallback((vid: string) => {
  639. const url = `${CMS_VIDEO_POST_DETAIL_BASE}${encodeURIComponent(vid)}/detail`;
  640. window.open(url, "_blank", "noopener,noreferrer");
  641. }, []);
  642. const openVideoDecodePage = useCallback(async (vid: string) => {
  643. setDecodeLoadingVid(vid);
  644. try {
  645. const resolvedBase = getResolvedApiBaseUrl();
  646. const baseWithSlash = resolvedBase.endsWith("/")
  647. ? resolvedBase
  648. : `${resolvedBase}/`;
  649. const url = new URL("videos/decode-url", baseWithSlash);
  650. url.searchParams.set("vid", vid);
  651. const response = await fetch(url.toString(), {
  652. method: "GET",
  653. headers: { Accept: "application/json" },
  654. });
  655. if (!response.ok) {
  656. throw new Error(`HTTP ${response.status}`);
  657. }
  658. const payload = (await response.json()) as { url2?: string | null };
  659. const raw = payload.url2;
  660. const trimmed =
  661. typeof raw === "string" ? raw.trim() : raw != null ? String(raw).trim() : "";
  662. if (!trimmed) {
  663. message.warning("不存在解构页面");
  664. return;
  665. }
  666. window.open(trimmed, "_blank", "noopener,noreferrer");
  667. } catch {
  668. message.error("解构页面地址查询失败");
  669. } finally {
  670. setDecodeLoadingVid(null);
  671. }
  672. }, []);
  673. const buildQueryUrl = useCallback(() => {
  674. const resolvedBase = getResolvedApiBaseUrl();
  675. const baseWithSlash = resolvedBase.endsWith("/")
  676. ? resolvedBase
  677. : `${resolvedBase}/`;
  678. const url = new URL(apiPath, baseWithSlash);
  679. if (mode === "period") {
  680. url.searchParams.set("period_days", String(periodDays));
  681. } else {
  682. url.searchParams.set(
  683. "month_total_pv_threshold",
  684. String(monthTotalPvThreshold)
  685. );
  686. url.searchParams.set("min_frequency", String(minFrequency));
  687. }
  688. url.searchParams.set("view_pv_count", String(viewPvCount));
  689. url.searchParams.set("min_contribution_score", String(minContributionScore));
  690. url.searchParams.set("rov_avg", String(rovAvg));
  691. return url.toString();
  692. }, [
  693. apiPath,
  694. mode,
  695. periodDays,
  696. monthTotalPvThreshold,
  697. minFrequency,
  698. viewPvCount,
  699. minContributionScore,
  700. rovAvg,
  701. ]);
  702. const buildExportUrl = useCallback(() => {
  703. const resolvedBase = getResolvedApiBaseUrl();
  704. const baseWithSlash = resolvedBase.endsWith("/")
  705. ? resolvedBase
  706. : `${resolvedBase}/`;
  707. const url = new URL(`${apiPath}/export`, baseWithSlash);
  708. if (mode === "period") {
  709. url.searchParams.set("period_days", String(periodDays));
  710. } else {
  711. url.searchParams.set(
  712. "month_total_pv_threshold",
  713. String(monthTotalPvThreshold)
  714. );
  715. url.searchParams.set("min_frequency", String(minFrequency));
  716. }
  717. url.searchParams.set("view_pv_count", String(viewPvCount));
  718. url.searchParams.set("min_contribution_score", String(minContributionScore));
  719. url.searchParams.set("rov_avg", String(rovAvg));
  720. return url.toString();
  721. }, [
  722. apiPath,
  723. mode,
  724. periodDays,
  725. monthTotalPvThreshold,
  726. minFrequency,
  727. viewPvCount,
  728. minContributionScore,
  729. rovAvg,
  730. ]);
  731. const handleExport = async () => {
  732. setExporting(true);
  733. try {
  734. await downloadExcelExport(buildExportUrl(), "特征点.xlsx");
  735. message.success("导出成功");
  736. } catch {
  737. message.error("导出失败,请重试");
  738. } finally {
  739. setExporting(false);
  740. }
  741. };
  742. const fetchAll = useCallback(async () => {
  743. setLoading(true);
  744. setError("");
  745. try {
  746. const response = await fetch(buildQueryUrl(), {
  747. method: "GET",
  748. headers: { Accept: "application/json" },
  749. });
  750. if (!response.ok) {
  751. throw new Error(`HTTP ${response.status}`);
  752. }
  753. const payload = (await response.json()) as ElementDemandResponse;
  754. setItems(payload.items ?? []);
  755. setPage(1);
  756. } catch (queryError) {
  757. setError(
  758. queryError instanceof Error ? queryError.message : "查询失败,请重试"
  759. );
  760. setItems([]);
  761. } finally {
  762. setLoading(false);
  763. setHasLoaded(true);
  764. }
  765. }, [buildQueryUrl]);
  766. /** 仅在本会话内首次进入该 Tab 时自动请求一次;数据留在 state 中,切走再回来不重复请求 */
  767. const autoFetchedOnceRef = useRef(false);
  768. useEffect(() => {
  769. if (!active) {
  770. return;
  771. }
  772. if (autoFetchedOnceRef.current) {
  773. return;
  774. }
  775. autoFetchedOnceRef.current = true;
  776. void fetchAll();
  777. }, [active, fetchAll]);
  778. const handleSubmit = () => {
  779. void fetchAll();
  780. };
  781. const resetDefaults = () => {
  782. setPeriodDays(7);
  783. setMonthTotalPvThreshold(20000);
  784. setMinFrequency(4);
  785. setViewPvCount(2000);
  786. setMinContributionScore(0.8);
  787. setRovAvg(0.04);
  788. setPage(1);
  789. };
  790. const total = items.length;
  791. const totalPages = Math.max(1, Math.ceil(total / pageSize));
  792. const pagedItems = useMemo(() => {
  793. const start = (page - 1) * pageSize;
  794. return items.slice(start, start + pageSize);
  795. }, [items, page, pageSize]);
  796. const columns: ColumnsType<ElementDemandItem> = useMemo(
  797. () => {
  798. const baseColumns: ColumnsType<ElementDemandItem> = [
  799. {
  800. title: "策略",
  801. dataIndex: "strategy",
  802. width: 120,
  803. render: (v) => v ?? "-",
  804. },
  805. {
  806. title: "特征点名称",
  807. dataIndex: "demand_name",
  808. ellipsis: true,
  809. render: (v) => v ?? "-",
  810. },
  811. {
  812. title: "权重",
  813. dataIndex: "weight",
  814. width: 110,
  815. render: (v) => (v === null || v === undefined ? "-" : String(v)),
  816. },
  817. ];
  818. const videoListColumn: ColumnsType<ElementDemandItem>[number] = {
  819. title: "视频列表",
  820. dataIndex: "video_list",
  821. width: 220,
  822. render: (_, record) => {
  823. const raw = record.video_list ?? "";
  824. const trimmed = raw.trim();
  825. if (!trimmed) {
  826. return "-";
  827. }
  828. const ids = parseVideoIdsFromList(raw);
  829. const label =
  830. ids.length > 0
  831. ? `共 ${ids.length} 条,点击查看`
  832. : trimmed.length > 36
  833. ? `${trimmed.slice(0, 36)}…`
  834. : trimmed;
  835. return (
  836. <Typography.Link
  837. onClick={() => {
  838. setVideoModalTitleName(record.demand_name ?? "");
  839. setVideoModalIds(ids);
  840. setVideoModalRaw(raw);
  841. setVideoModalOpen(true);
  842. }}
  843. >
  844. {label}
  845. </Typography.Link>
  846. );
  847. },
  848. };
  849. const videoCountColumn: ColumnsType<ElementDemandItem>[number] = {
  850. title: "视频数",
  851. dataIndex: "video_count",
  852. width: 100,
  853. render: (v) => v ?? "-",
  854. };
  855. if (mode === "monthly") {
  856. baseColumns.push({
  857. title: "频次",
  858. dataIndex: "frequency",
  859. width: 90,
  860. render: (v) => (v === null || v === undefined ? "-" : String(v)),
  861. });
  862. baseColumns.push(videoCountColumn);
  863. baseColumns.push({
  864. title: "月份列表",
  865. dataIndex: "month_list",
  866. width: 180,
  867. render: (_, record) => {
  868. const raw = record.month_list ?? "";
  869. const trimmed = raw.trim();
  870. if (!trimmed) {
  871. return "-";
  872. }
  873. const months = parseVideoIdsFromList(raw);
  874. return (
  875. <Typography.Link
  876. onClick={() => {
  877. setMonthModalTitleName(record.demand_name ?? "");
  878. setMonthModalMonths(months);
  879. setMonthModalRaw(raw);
  880. setMonthModalOpen(true);
  881. }}
  882. >
  883. {formatMonthListPreview(raw)}
  884. </Typography.Link>
  885. );
  886. },
  887. });
  888. } else {
  889. baseColumns.push(videoCountColumn);
  890. }
  891. baseColumns.push(videoListColumn);
  892. return baseColumns;
  893. },
  894. [mode]
  895. );
  896. return (
  897. <div className="panel-sheet">
  898. <section className="panel-section panel-section--filters">
  899. <header className="panel-section-head">
  900. <span className="panel-section-accent" aria-hidden />
  901. <Typography.Title level={5} className="panel-section-title">
  902. 筛选条件
  903. </Typography.Title>
  904. </header>
  905. <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
  906. <div className="filter-row second-row element-demand-filter-row">
  907. {mode === "period" ? (
  908. <Form.Item label={periodDaysLabel}>
  909. <InputNumber
  910. min={0}
  911. precision={0}
  912. value={periodDays}
  913. onChange={(v) => setPeriodDays(v ?? 7)}
  914. />
  915. </Form.Item>
  916. ) : null}
  917. <Form.Item label="当日分发曝光PV限制">
  918. <InputNumber
  919. min={0}
  920. precision={0}
  921. value={viewPvCount}
  922. onChange={(v) => setViewPvCount(v ?? 0)}
  923. />
  924. </Form.Item>
  925. {mode === "monthly" ? (
  926. <Form.Item label="月累计分发曝光PV阈值">
  927. <InputNumber
  928. min={0}
  929. precision={0}
  930. value={monthTotalPvThreshold}
  931. onChange={(v) => setMonthTotalPvThreshold(v ?? 0)}
  932. />
  933. </Form.Item>
  934. ) : null}
  935. <Form.Item label="贡献分限制">
  936. <InputNumber
  937. min={0}
  938. step={0.01}
  939. value={minContributionScore}
  940. onChange={(v) => setMinContributionScore(v ?? 0)}
  941. />
  942. </Form.Item>
  943. <Form.Item
  944. label={
  945. mode === "monthly" ? "累加平均ROV限制" : "平均ROV限制"
  946. }
  947. >
  948. <InputNumber
  949. min={0}
  950. step={0.001}
  951. value={rovAvg}
  952. onChange={(v) => setRovAvg(v ?? 0)}
  953. />
  954. </Form.Item>
  955. {mode === "monthly" ? (
  956. <Form.Item label="元素频次限制(有效月份数)">
  957. <InputNumber
  958. min={0}
  959. precision={0}
  960. value={minFrequency}
  961. onChange={(v) => setMinFrequency(v ?? 0)}
  962. />
  963. </Form.Item>
  964. ) : null}
  965. <Form.Item label=" ">
  966. <Button type="primary" htmlType="submit" loading={loading}>
  967. 查询
  968. </Button>
  969. </Form.Item>
  970. <Form.Item label=" ">
  971. <Button type="default" onClick={resetDefaults}>
  972. 恢复默认
  973. </Button>
  974. </Form.Item>
  975. </div>
  976. </Form>
  977. {error ? (
  978. <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
  979. ) : null}
  980. </section>
  981. <section className="panel-section panel-section--table">
  982. <div className="table-toolbar">
  983. <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
  984. {tableDetailTitle}
  985. </Typography.Title>
  986. <Space size={8} wrap className="table-toolbar-meta">
  987. <span className="meta-chip">本地 {total} 条</span>
  988. <span className="meta-chip">
  989. 第 {page} / {totalPages} 页
  990. </span>
  991. <Button
  992. type="default"
  993. loading={exporting}
  994. disabled={loading}
  995. onClick={() => void handleExport()}
  996. >
  997. 导出 Excel
  998. </Button>
  999. </Space>
  1000. </div>
  1001. {loading && !hasLoaded ? (
  1002. <Skeleton active paragraph={{ rows: 10 }} />
  1003. ) : (
  1004. <div className="table-wrap">
  1005. <Table
  1006. rowKey={(record) =>
  1007. String(record.demand_id ?? "").trim() ||
  1008. `${record.strategy ?? ""}|${record.demand_name ?? ""}`
  1009. }
  1010. loading={loading}
  1011. columns={columns}
  1012. dataSource={pagedItems}
  1013. pagination={false}
  1014. scroll={{ x: mode === "monthly" ? 1150 : 880 }}
  1015. rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
  1016. />
  1017. </div>
  1018. )}
  1019. <Modal
  1020. title={`视频列表${videoModalTitleName ? ` — ${videoModalTitleName}` : ""}`}
  1021. open={videoModalOpen}
  1022. onCancel={() => setVideoModalOpen(false)}
  1023. footer={
  1024. <Button type="primary" onClick={() => setVideoModalOpen(false)}>
  1025. 关闭
  1026. </Button>
  1027. }
  1028. width={900}
  1029. destroyOnHidden
  1030. >
  1031. {videoModalIds.length > 0 ? (
  1032. <List
  1033. size="small"
  1034. bordered
  1035. dataSource={videoModalIds}
  1036. style={{ maxHeight: 480, overflow: "auto" }}
  1037. renderItem={(vid, idx) => (
  1038. <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
  1039. <div
  1040. style={{
  1041. display: "flex",
  1042. alignItems: "center",
  1043. width: "100%",
  1044. flexWrap: "nowrap",
  1045. gap: 12,
  1046. }}
  1047. >
  1048. <span
  1049. style={{
  1050. display: "inline-flex",
  1051. alignItems: "baseline",
  1052. gap: "0.25em",
  1053. whiteSpace: "nowrap",
  1054. minWidth: 0,
  1055. }}
  1056. >
  1057. <Typography.Text type="secondary" style={{ margin: 0 }}>
  1058. {idx + 1}.
  1059. </Typography.Text>
  1060. <Typography.Text copyable={{ text: vid }} style={{ margin: 0 }}>
  1061. {vid}
  1062. </Typography.Text>
  1063. </span>
  1064. <Space size={4} style={{ marginLeft: "auto", flexShrink: 0 }}>
  1065. <Button
  1066. type="link"
  1067. size="small"
  1068. onClick={() => openVideoCmsDetail(vid)}
  1069. >
  1070. 查看视频详情
  1071. </Button>
  1072. <Button
  1073. type="link"
  1074. size="small"
  1075. loading={decodeLoadingVid === vid}
  1076. onClick={() => void openVideoDecodePage(vid)}
  1077. >
  1078. 查看视频解构
  1079. </Button>
  1080. </Space>
  1081. </div>
  1082. </List.Item>
  1083. )}
  1084. />
  1085. ) : (
  1086. <Typography.Paragraph
  1087. copyable={{ text: videoModalRaw }}
  1088. style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
  1089. >
  1090. {videoModalRaw || "(空)"}
  1091. </Typography.Paragraph>
  1092. )}
  1093. </Modal>
  1094. <Modal
  1095. title={`月份列表${monthModalTitleName ? ` — ${monthModalTitleName}` : ""}`}
  1096. open={monthModalOpen}
  1097. onCancel={() => setMonthModalOpen(false)}
  1098. footer={
  1099. <Button type="primary" onClick={() => setMonthModalOpen(false)}>
  1100. 关闭
  1101. </Button>
  1102. }
  1103. width={560}
  1104. destroyOnHidden
  1105. >
  1106. {monthModalMonths.length > 0 ? (
  1107. <List
  1108. size="small"
  1109. bordered
  1110. dataSource={monthModalMonths}
  1111. style={{ maxHeight: 480, overflow: "auto" }}
  1112. renderItem={(ym, idx) => (
  1113. <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
  1114. <Typography.Text copyable={{ text: ym }}>
  1115. {idx + 1}. {formatYmDisplay(ym)}
  1116. </Typography.Text>
  1117. </List.Item>
  1118. )}
  1119. />
  1120. ) : (
  1121. <Typography.Paragraph
  1122. copyable={{ text: monthModalRaw }}
  1123. style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
  1124. >
  1125. {monthModalRaw || "(空)"}
  1126. </Typography.Paragraph>
  1127. )}
  1128. </Modal>
  1129. <div className="panel-footer">
  1130. <Pagination
  1131. current={page}
  1132. total={total}
  1133. pageSize={pageSize}
  1134. showSizeChanger
  1135. pageSizeOptions={["10", "20", "50", "100"]}
  1136. showQuickJumper
  1137. showTotal={(t) => `共 ${t} 条`}
  1138. onChange={(nextPage, size) => {
  1139. const nextSize = size ?? pageSize;
  1140. if (nextSize !== pageSize) {
  1141. setPageSize(nextSize);
  1142. setPage(1);
  1143. return;
  1144. }
  1145. setPage(nextPage);
  1146. }}
  1147. onShowSizeChange={(_, size) => {
  1148. setPageSize(size);
  1149. setPage(1);
  1150. }}
  1151. />
  1152. </div>
  1153. </section>
  1154. </div>
  1155. );
  1156. }
  1157. function SolarCalendarPanel({ active }: { active: boolean }) {
  1158. return (
  1159. <ElementDemandQueryPanel
  1160. active={active}
  1161. apiPath="element-demands/solar-calendar"
  1162. periodDaysLabel="区间天数(含去年阳历今日)"
  1163. tableDetailTitle="去年同期阳历特征点明细"
  1164. />
  1165. );
  1166. }
  1167. function LunarCalendarPanel({ active }: { active: boolean }) {
  1168. return (
  1169. <ElementDemandQueryPanel
  1170. active={active}
  1171. apiPath="element-demands/lunar-calendar"
  1172. periodDaysLabel="区间天数(含去年阴历今日)"
  1173. tableDetailTitle="去年同期阴历特征点明细"
  1174. />
  1175. );
  1176. }
  1177. function MonthlyDemandPanel({ active }: { active: boolean }) {
  1178. return (
  1179. <ElementDemandQueryPanel
  1180. active={active}
  1181. apiPath="element-demands/monthly"
  1182. periodDaysLabel=""
  1183. tableDetailTitle="逐月特征点明细"
  1184. mode="monthly"
  1185. />
  1186. );
  1187. }
  1188. function App() {
  1189. const [activeTab, setActiveTab] = useState("demand-pool");
  1190. const tabItems = useMemo(
  1191. () => [
  1192. {
  1193. key: "demand-pool",
  1194. label: "需求池",
  1195. children: <DemandPoolPanel />,
  1196. },
  1197. {
  1198. key: "solar-calendar",
  1199. label: "去年同期阳历特征点查询",
  1200. children: (
  1201. <SolarCalendarPanel active={activeTab === "solar-calendar"} />
  1202. ),
  1203. },
  1204. {
  1205. key: "lunar-calendar",
  1206. label: "去年同期阴历特征点查询",
  1207. children: (
  1208. <LunarCalendarPanel active={activeTab === "lunar-calendar"} />
  1209. ),
  1210. },
  1211. {
  1212. key: "monthly-demand",
  1213. label: "逐月特征点查询",
  1214. children: (
  1215. <MonthlyDemandPanel active={activeTab === "monthly-demand"} />
  1216. ),
  1217. },
  1218. ],
  1219. [activeTab],
  1220. );
  1221. return (
  1222. <div className="page">
  1223. <div className="hero">
  1224. <Typography.Title level={2} className="hero-title">
  1225. 需求池数据看板
  1226. </Typography.Title>
  1227. <div className="hero-subtitle">
  1228. <Tag color="blue">数据检索</Tag>
  1229. <Tag color="cyan">策略筛选</Tag>
  1230. <Tag color="geekblue">日期范围分析</Tag>
  1231. <Tag color="purple">需求筛选</Tag>
  1232. </div>
  1233. </div>
  1234. <div className="dashboard-shell">
  1235. <Tabs
  1236. className="main-tabs demand-nav-tabs"
  1237. activeKey={activeTab}
  1238. onChange={setActiveTab}
  1239. tabBarGutter={16}
  1240. items={tabItems}
  1241. />
  1242. </div>
  1243. </div>
  1244. );
  1245. }
  1246. export default App;