| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311 |
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import {
- Alert,
- Button,
- DatePicker,
- Form,
- Input,
- InputNumber,
- List,
- message,
- Modal,
- Pagination,
- Select,
- Skeleton,
- Space,
- Table,
- Tabs,
- Tag,
- Typography,
- } from "antd";
- import type { ColumnsType } from "antd/es/table";
- import type { SortOrder } from "antd/es/table/interface";
- import dayjs from "dayjs";
- import type { Dayjs } from "dayjs";
- type DemandPoolItem = {
- id: number;
- strategy: string | null;
- demand_name: string | null;
- type: string | null;
- weight: number | null;
- video_count: number | null;
- dt: string | null;
- };
- type QueryResponse = {
- total: number;
- page: number;
- page_size: number;
- items: DemandPoolItem[];
- };
- type StrategyOption = {
- strategy: string;
- record_count: number;
- };
- type StrategyResponse = {
- items: StrategyOption[];
- };
- type ElementDemandItem = {
- strategy: string | null;
- demand_id: string | null;
- demand_name: string | null;
- weight: number | null;
- video_count: number | null;
- video_list: string | null;
- month_list?: string | null;
- frequency?: number | null;
- ext_info: string | null;
- };
- type ElementDemandResponse = {
- items: ElementDemandItem[];
- };
- function parseVideoIdsFromList(raw: string | null): string[] {
- const t = (raw ?? "").trim();
- if (!t) {
- return [];
- }
- try {
- const parsed: unknown = JSON.parse(t);
- if (Array.isArray(parsed)) {
- return parsed.map((item) => String(item));
- }
- } catch {
- /* 非 JSON 数组则走下方原始文本展示 */
- }
- return [];
- }
- function formatYmDisplay(ym: string): string {
- if (/^\d{6}$/.test(ym)) {
- return `${ym.slice(0, 4)}-${ym.slice(4, 6)}`;
- }
- return ym;
- }
- function formatMonthListPreview(raw: string | null): string {
- const months = parseVideoIdsFromList(raw);
- if (months.length > 0) {
- return `共 ${months.length} 个月,点击查看`;
- }
- const trimmed = (raw ?? "").trim();
- if (!trimmed) {
- return "-";
- }
- return trimmed.length > 36 ? `${trimmed.slice(0, 36)}…` : trimmed;
- }
- const API_BASE_URL =
- import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
- const getResolvedApiBaseUrl = () => {
- if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
- return API_BASE_URL;
- }
- return new URL(API_BASE_URL, window.location.origin).toString();
- };
- function parseFilenameFromContentDisposition(header: string | null): string | null {
- if (!header) {
- return null;
- }
- const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
- if (utf8Match?.[1]) {
- try {
- return decodeURIComponent(utf8Match[1]);
- } catch {
- return utf8Match[1];
- }
- }
- const asciiMatch = header.match(/filename="([^"]+)"/i);
- return asciiMatch?.[1] ?? null;
- }
- async function downloadExcelExport(url: string, defaultFilename: string) {
- const response = await fetch(url, { method: "GET" });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const blob = await response.blob();
- const filename =
- parseFilenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
- defaultFilename;
- const objectUrl = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = objectUrl;
- link.download = filename;
- link.click();
- URL.revokeObjectURL(objectUrl);
- }
- /** 票圈后台:视频详情页(新标签打开) */
- const CMS_VIDEO_POST_DETAIL_BASE =
- "https://admin.piaoquantv.com/cms/post-detail/";
- function DemandPoolPanel() {
- const [strategies, setStrategies] = useState<string[]>([]);
- const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
- const today = dayjs();
- return [today, today];
- });
- const [strategyOptions, setStrategyOptions] = useState<StrategyOption[]>([]);
- const [currentPage, setCurrentPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
- const [refreshTick, setRefreshTick] = useState(0);
- const [minWeightInput, setMinWeightInput] = useState<number | null>(null);
- const [maxWeightInput, setMaxWeightInput] = useState<number | null>(null);
- const [appliedMinWeight, setAppliedMinWeight] = useState<number | null>(null);
- const [appliedMaxWeight, setAppliedMaxWeight] = useState<number | null>(null);
- const [demandNameInput, setDemandNameInput] = useState("");
- const [appliedDemandName, setAppliedDemandName] = useState("");
- const [sortBy, setSortBy] = useState("weight");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
- const [loading, setLoading] = useState(false);
- const [exporting, setExporting] = useState(false);
- const [hasLoaded, setHasLoaded] = useState(false);
- const [loadingStrategies, setLoadingStrategies] = useState(false);
- const [error, setError] = useState("");
- const [data, setData] = useState<QueryResponse>({
- total: 0,
- page: 1,
- page_size: pageSize,
- items: []
- });
- const startDate = dateRange?.[0]?.format("YYYYMMDD") ?? "";
- const endDate = dateRange?.[1]?.format("YYYYMMDD") ?? "";
- const dateRangeInvalid =
- Boolean(startDate) && Boolean(endDate) && startDate > endDate;
- const weightRangeInvalid =
- minWeightInput !== null && maxWeightInput !== null && minWeightInput > maxWeightInput;
- const queryKey = JSON.stringify({
- strategies,
- startDate,
- endDate,
- appliedMinWeight,
- appliedMaxWeight,
- appliedDemandName,
- sortBy,
- sortOrder,
- currentPage,
- pageSize,
- refreshTick,
- });
- const buildRequestUrl = (page: number, size: number = pageSize) => {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL("demand-pool", baseWithSlash);
- for (const strategyValue of strategies) {
- url.searchParams.append("strategy", strategyValue);
- }
- if (startDate) {
- url.searchParams.set("start_dt", startDate);
- }
- if (endDate) {
- url.searchParams.set("end_dt", endDate);
- }
- if (appliedMinWeight !== null) {
- url.searchParams.set("min_weight", String(appliedMinWeight));
- }
- if (appliedMaxWeight !== null) {
- url.searchParams.set("max_weight", String(appliedMaxWeight));
- }
- const trimmedDemandName = appliedDemandName.trim();
- if (trimmedDemandName) {
- url.searchParams.set("demand_name", trimmedDemandName);
- }
- url.searchParams.set("sort_by", sortBy);
- url.searchParams.set("sort_order", sortOrder);
- url.searchParams.set("page", String(page));
- url.searchParams.set("page_size", String(size));
- return url.toString();
- };
- const buildExportUrl = () => {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL("demand-pool/export", baseWithSlash);
- for (const strategyValue of strategies) {
- url.searchParams.append("strategy", strategyValue);
- }
- if (startDate) {
- url.searchParams.set("start_dt", startDate);
- }
- if (endDate) {
- url.searchParams.set("end_dt", endDate);
- }
- if (appliedMinWeight !== null) {
- url.searchParams.set("min_weight", String(appliedMinWeight));
- }
- if (appliedMaxWeight !== null) {
- url.searchParams.set("max_weight", String(appliedMaxWeight));
- }
- const trimmedDemandName = appliedDemandName.trim();
- if (trimmedDemandName) {
- url.searchParams.set("demand_name", trimmedDemandName);
- }
- url.searchParams.set("sort_by", sortBy);
- url.searchParams.set("sort_order", sortOrder);
- return url.toString();
- };
- const handleExport = async () => {
- if (dateRangeInvalid || weightRangeInvalid) {
- return;
- }
- setExporting(true);
- try {
- await downloadExcelExport(buildExportUrl(), "需求池.xlsx");
- message.success("导出成功");
- } catch {
- message.error("导出失败,请重试");
- } finally {
- setExporting(false);
- }
- };
- const buildStrategyUrl = () => {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL("demand-pool/strategies", baseWithSlash);
- if (startDate) {
- url.searchParams.set("start_dt", startDate);
- }
- if (endDate) {
- url.searchParams.set("end_dt", endDate);
- }
- if (appliedMinWeight !== null) {
- url.searchParams.set("min_weight", String(appliedMinWeight));
- }
- if (appliedMaxWeight !== null) {
- url.searchParams.set("max_weight", String(appliedMaxWeight));
- }
- return url.toString();
- };
- const fetchData = async (page: number, size: number = pageSize) => {
- setLoading(true);
- setError("");
- try {
- const response = await fetch(buildRequestUrl(page, size), {
- method: "GET",
- headers: { Accept: "application/json" }
- });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const payload = (await response.json()) as QueryResponse;
- setData(payload);
- } catch (queryError) {
- setError(
- queryError instanceof Error ? queryError.message : "查询失败,请重试"
- );
- } finally {
- setLoading(false);
- setHasLoaded(true);
- }
- };
- const fetchStrategies = async () => {
- setLoadingStrategies(true);
- try {
- const response = await fetch(buildStrategyUrl(), {
- method: "GET",
- headers: { Accept: "application/json" }
- });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const payload = (await response.json()) as StrategyResponse;
- setStrategyOptions(payload.items);
- const availableSet = new Set(payload.items.map((item) => item.strategy));
- const allStrategies = payload.items.map((item) => item.strategy);
- setStrategies((prev) => {
- if (prev.length === 0) {
- return allStrategies;
- }
- const filtered = prev.filter((value) => availableSet.has(value));
- return filtered.length > 0 ? filtered : allStrategies;
- });
- } catch {
- setStrategyOptions([]);
- } finally {
- setLoadingStrategies(false);
- }
- };
- useEffect(() => {
- void fetchStrategies();
- }, [startDate, endDate, appliedMinWeight, appliedMaxWeight]);
- useEffect(() => {
- if (dateRangeInvalid || weightRangeInvalid || loadingStrategies) {
- return;
- }
- if (strategyOptions.length > 0 && strategies.length === 0) {
- return;
- }
- void fetchData(currentPage, pageSize);
- }, [queryKey, loadingStrategies, dateRangeInvalid, weightRangeInvalid, strategyOptions.length]);
- const handleSubmit = async () => {
- if (dateRangeInvalid || weightRangeInvalid) {
- return;
- }
- setAppliedMinWeight(minWeightInput);
- setAppliedMaxWeight(maxWeightInput);
- setAppliedDemandName(demandNameInput.trim());
- setCurrentPage(1);
- setRefreshTick((value) => value + 1);
- };
- const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
- const handlePageSizeChange = (value: number) => {
- setPageSize(value);
- setCurrentPage(1);
- };
- const resetFilters = () => {
- const today = dayjs();
- setDateRange([today, today]);
- setStrategies(strategyOptions.map((item) => item.strategy));
- setMinWeightInput(null);
- setMaxWeightInput(null);
- setAppliedMinWeight(null);
- setAppliedMaxWeight(null);
- setDemandNameInput("");
- setAppliedDemandName("");
- setSortBy("weight");
- setSortOrder("desc");
- setCurrentPage(1);
- };
- const getSortOrderForColumn = (columnKey: string): SortOrder | null => {
- if (sortBy !== columnKey) {
- return null;
- }
- return sortOrder === "asc" ? "ascend" : "descend";
- };
- const columns: ColumnsType<DemandPoolItem> = useMemo(
- () => [
- { title: "ID", dataIndex: "id", width: 90, sorter: true, sortOrder: getSortOrderForColumn("id") },
- {
- title: "策略名",
- dataIndex: "strategy",
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("strategy"),
- },
- {
- title: "需求名称",
- dataIndex: "demand_name",
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("demand_name"),
- },
- {
- title: "需求类型",
- dataIndex: "type",
- width: 120,
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("type"),
- },
- {
- title: "权重",
- dataIndex: "weight",
- width: 120,
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("weight"),
- },
- {
- title: "视频数量",
- dataIndex: "video_count",
- width: 120,
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("video_count"),
- },
- {
- title: "日期",
- dataIndex: "dt",
- width: 120,
- render: (v) => v ?? "-",
- sorter: true,
- sortOrder: getSortOrderForColumn("dt"),
- },
- ],
- [sortBy, sortOrder]
- );
- return (
- <div className="panel-sheet">
- <section className="panel-section panel-section--filters">
- <header className="panel-section-head">
- <span className="panel-section-accent" aria-hidden />
- <Typography.Title level={5} className="panel-section-title">
- 筛选条件
- </Typography.Title>
- </header>
- <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
- <div className="filter-row">
- <Form.Item label="策略名" className="strategy-item">
- <Select
- className="strategy-select"
- placeholder="请选择策略(支持多选)"
- value={strategies}
- onChange={setStrategies}
- loading={loadingStrategies}
- mode="multiple"
- allowClear
- maxTagCount="responsive"
- showSearch
- optionFilterProp="label"
- options={strategyOptions.map((item) => ({
- label: `${item.strategy} (${item.record_count})`,
- value: item.strategy,
- }))}
- />
- </Form.Item>
- </div>
- <div className="filter-row second-row">
- <Form.Item label="日期区间">
- <DatePicker.RangePicker
- value={dateRange}
- onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
- allowClear
- />
- </Form.Item>
- <Form.Item label="权重分">
- <Space>
- <InputNumber
- placeholder="最小值"
- value={minWeightInput}
- onChange={(value) => setMinWeightInput(value)}
- />
- <span>-</span>
- <InputNumber
- placeholder="最大值"
- value={maxWeightInput}
- onChange={(value) => setMaxWeightInput(value)}
- />
- </Space>
- </Form.Item>
- <Form.Item label="需求名称">
- <Input
- allowClear
- value={demandNameInput}
- onChange={(e) => setDemandNameInput(e.target.value)}
- />
- </Form.Item>
- <Form.Item label=" ">
- <Button
- type="primary"
- htmlType="submit"
- loading={loading}
- disabled={dateRangeInvalid || weightRangeInvalid}
- >
- 查询
- </Button>
- </Form.Item>
- <Form.Item label=" ">
- <Space>
- <Button type="default" onClick={resetFilters}>
- 重置
- </Button>
- </Space>
- </Form.Item>
- </div>
- </Form>
- {dateRangeInvalid ? (
- <Alert
- style={{ marginTop: 12 }}
- type="error"
- showIcon
- message="开始日期不能晚于结束日期"
- />
- ) : null}
- {weightRangeInvalid ? (
- <Alert
- style={{ marginTop: 12 }}
- type="error"
- showIcon
- message="最小权重不能大于最大权重"
- />
- ) : null}
- {error ? (
- <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
- ) : null}
- </section>
- <section className="panel-section panel-section--table">
- <div className="table-toolbar">
- <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
- 需求明细
- </Typography.Title>
- <Space size={8} wrap className="table-toolbar-meta">
- <span className="meta-chip">已选策略 {strategies.length}</span>
- <span className="meta-chip">共 {data.total} 条</span>
- <span className="meta-chip">
- 第 {currentPage} / {totalPages} 页
- </span>
- <Button
- type="default"
- loading={exporting}
- disabled={dateRangeInvalid || weightRangeInvalid || loading}
- onClick={() => void handleExport()}
- >
- 导出 Excel
- </Button>
- </Space>
- </div>
- {loading && !hasLoaded ? (
- <Skeleton active paragraph={{ rows: 10 }} />
- ) : (
- <div className="table-wrap">
- <Table
- rowKey="id"
- loading={loading}
- columns={columns}
- dataSource={data.items}
- pagination={false}
- scroll={{ x: 1060 }}
- rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
- onChange={(_, __, sorter) => {
- if (Array.isArray(sorter)) {
- return;
- }
- const nextField = typeof sorter.field === "string" ? sorter.field : null;
- const nextOrder = sorter.order;
- if (!nextField || !nextOrder) {
- setSortBy("weight");
- setSortOrder("desc");
- setCurrentPage(1);
- return;
- }
- setSortBy(nextField);
- setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
- setCurrentPage(1);
- }}
- />
- </div>
- )}
- <div className="panel-footer">
- <Pagination
- current={currentPage}
- total={data.total}
- pageSize={pageSize}
- showSizeChanger
- pageSizeOptions={["10", "20", "50", "100"]}
- showQuickJumper
- showTotal={(total) => `共 ${total} 条`}
- onChange={(page, size) => {
- const nextSize = size ?? pageSize;
- setCurrentPage(page);
- if (nextSize !== pageSize) {
- setPageSize(nextSize);
- return;
- }
- }}
- onShowSizeChange={(_, size) => {
- handlePageSizeChange(size);
- }}
- />
- </div>
- </section>
- </div>
- );
- }
- function ElementDemandQueryPanel({
- active,
- apiPath,
- periodDaysLabel,
- tableDetailTitle,
- mode = "period",
- }: {
- active: boolean;
- apiPath: string;
- periodDaysLabel: string;
- tableDetailTitle: string;
- /** period:阳历/阴历同期;monthly:逐月回溯窗口 */
- mode?: "period" | "monthly";
- }) {
- const [periodDays, setPeriodDays] = useState(7);
- const [monthTotalPvThreshold, setMonthTotalPvThreshold] = useState(20000);
- const [minFrequency, setMinFrequency] = useState(4);
- const [viewPvCount, setViewPvCount] = useState(2000);
- const [minContributionScore, setMinContributionScore] = useState(0.8);
- const [rovAvg, setRovAvg] = useState(0.04);
- const [items, setItems] = useState<ElementDemandItem[]>([]);
- const [loading, setLoading] = useState(false);
- const [exporting, setExporting] = useState(false);
- const [hasLoaded, setHasLoaded] = useState(false);
- const [error, setError] = useState("");
- const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
- const [videoModalOpen, setVideoModalOpen] = useState(false);
- const [videoModalTitleName, setVideoModalTitleName] = useState("");
- const [videoModalIds, setVideoModalIds] = useState<string[]>([]);
- const [videoModalRaw, setVideoModalRaw] = useState("");
- const [monthModalOpen, setMonthModalOpen] = useState(false);
- const [monthModalTitleName, setMonthModalTitleName] = useState("");
- const [monthModalMonths, setMonthModalMonths] = useState<string[]>([]);
- const [monthModalRaw, setMonthModalRaw] = useState("");
- const [decodeLoadingVid, setDecodeLoadingVid] = useState<string | null>(null);
- const openVideoCmsDetail = useCallback((vid: string) => {
- const url = `${CMS_VIDEO_POST_DETAIL_BASE}${encodeURIComponent(vid)}/detail`;
- window.open(url, "_blank", "noopener,noreferrer");
- }, []);
- const openVideoDecodePage = useCallback(async (vid: string) => {
- setDecodeLoadingVid(vid);
- try {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL("videos/decode-url", baseWithSlash);
- url.searchParams.set("vid", vid);
- const response = await fetch(url.toString(), {
- method: "GET",
- headers: { Accept: "application/json" },
- });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const payload = (await response.json()) as { url2?: string | null };
- const raw = payload.url2;
- const trimmed =
- typeof raw === "string" ? raw.trim() : raw != null ? String(raw).trim() : "";
- if (!trimmed) {
- message.warning("不存在解构页面");
- return;
- }
- window.open(trimmed, "_blank", "noopener,noreferrer");
- } catch {
- message.error("解构页面地址查询失败");
- } finally {
- setDecodeLoadingVid(null);
- }
- }, []);
- const buildQueryUrl = useCallback(() => {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL(apiPath, baseWithSlash);
- if (mode === "period") {
- url.searchParams.set("period_days", String(periodDays));
- } else {
- url.searchParams.set(
- "month_total_pv_threshold",
- String(monthTotalPvThreshold)
- );
- url.searchParams.set("min_frequency", String(minFrequency));
- }
- url.searchParams.set("view_pv_count", String(viewPvCount));
- url.searchParams.set("min_contribution_score", String(minContributionScore));
- url.searchParams.set("rov_avg", String(rovAvg));
- return url.toString();
- }, [
- apiPath,
- mode,
- periodDays,
- monthTotalPvThreshold,
- minFrequency,
- viewPvCount,
- minContributionScore,
- rovAvg,
- ]);
- const buildExportUrl = useCallback(() => {
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL(`${apiPath}/export`, baseWithSlash);
- if (mode === "period") {
- url.searchParams.set("period_days", String(periodDays));
- } else {
- url.searchParams.set(
- "month_total_pv_threshold",
- String(monthTotalPvThreshold)
- );
- url.searchParams.set("min_frequency", String(minFrequency));
- }
- url.searchParams.set("view_pv_count", String(viewPvCount));
- url.searchParams.set("min_contribution_score", String(minContributionScore));
- url.searchParams.set("rov_avg", String(rovAvg));
- return url.toString();
- }, [
- apiPath,
- mode,
- periodDays,
- monthTotalPvThreshold,
- minFrequency,
- viewPvCount,
- minContributionScore,
- rovAvg,
- ]);
- const handleExport = async () => {
- setExporting(true);
- try {
- await downloadExcelExport(buildExportUrl(), "特征点.xlsx");
- message.success("导出成功");
- } catch {
- message.error("导出失败,请重试");
- } finally {
- setExporting(false);
- }
- };
- const fetchAll = useCallback(async () => {
- setLoading(true);
- setError("");
- try {
- const response = await fetch(buildQueryUrl(), {
- method: "GET",
- headers: { Accept: "application/json" },
- });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const payload = (await response.json()) as ElementDemandResponse;
- setItems(payload.items ?? []);
- setPage(1);
- } catch (queryError) {
- setError(
- queryError instanceof Error ? queryError.message : "查询失败,请重试"
- );
- setItems([]);
- } finally {
- setLoading(false);
- setHasLoaded(true);
- }
- }, [buildQueryUrl]);
- /** 仅在本会话内首次进入该 Tab 时自动请求一次;数据留在 state 中,切走再回来不重复请求 */
- const autoFetchedOnceRef = useRef(false);
- useEffect(() => {
- if (!active) {
- return;
- }
- if (autoFetchedOnceRef.current) {
- return;
- }
- autoFetchedOnceRef.current = true;
- void fetchAll();
- }, [active, fetchAll]);
- const handleSubmit = () => {
- void fetchAll();
- };
- const resetDefaults = () => {
- setPeriodDays(7);
- setMonthTotalPvThreshold(20000);
- setMinFrequency(4);
- setViewPvCount(2000);
- setMinContributionScore(0.8);
- setRovAvg(0.04);
- setPage(1);
- };
- const total = items.length;
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
- const pagedItems = useMemo(() => {
- const start = (page - 1) * pageSize;
- return items.slice(start, start + pageSize);
- }, [items, page, pageSize]);
- const columns: ColumnsType<ElementDemandItem> = useMemo(
- () => {
- const baseColumns: ColumnsType<ElementDemandItem> = [
- {
- title: "策略",
- dataIndex: "strategy",
- width: 120,
- render: (v) => v ?? "-",
- },
- {
- title: "特征点名称",
- dataIndex: "demand_name",
- ellipsis: true,
- render: (v) => v ?? "-",
- },
- {
- title: "权重",
- dataIndex: "weight",
- width: 110,
- render: (v) => (v === null || v === undefined ? "-" : String(v)),
- },
- ];
- const videoListColumn: ColumnsType<ElementDemandItem>[number] = {
- title: "视频列表",
- dataIndex: "video_list",
- width: 220,
- render: (_, record) => {
- const raw = record.video_list ?? "";
- const trimmed = raw.trim();
- if (!trimmed) {
- return "-";
- }
- const ids = parseVideoIdsFromList(raw);
- const label =
- ids.length > 0
- ? `共 ${ids.length} 条,点击查看`
- : trimmed.length > 36
- ? `${trimmed.slice(0, 36)}…`
- : trimmed;
- return (
- <Typography.Link
- onClick={() => {
- setVideoModalTitleName(record.demand_name ?? "");
- setVideoModalIds(ids);
- setVideoModalRaw(raw);
- setVideoModalOpen(true);
- }}
- >
- {label}
- </Typography.Link>
- );
- },
- };
- const videoCountColumn: ColumnsType<ElementDemandItem>[number] = {
- title: "视频数",
- dataIndex: "video_count",
- width: 100,
- render: (v) => v ?? "-",
- };
- if (mode === "monthly") {
- baseColumns.push({
- title: "频次",
- dataIndex: "frequency",
- width: 90,
- render: (v) => (v === null || v === undefined ? "-" : String(v)),
- });
- baseColumns.push(videoCountColumn);
- baseColumns.push({
- title: "月份列表",
- dataIndex: "month_list",
- width: 180,
- render: (_, record) => {
- const raw = record.month_list ?? "";
- const trimmed = raw.trim();
- if (!trimmed) {
- return "-";
- }
- const months = parseVideoIdsFromList(raw);
- return (
- <Typography.Link
- onClick={() => {
- setMonthModalTitleName(record.demand_name ?? "");
- setMonthModalMonths(months);
- setMonthModalRaw(raw);
- setMonthModalOpen(true);
- }}
- >
- {formatMonthListPreview(raw)}
- </Typography.Link>
- );
- },
- });
- } else {
- baseColumns.push(videoCountColumn);
- }
- baseColumns.push(videoListColumn);
- return baseColumns;
- },
- [mode]
- );
- return (
- <div className="panel-sheet">
- <section className="panel-section panel-section--filters">
- <header className="panel-section-head">
- <span className="panel-section-accent" aria-hidden />
- <Typography.Title level={5} className="panel-section-title">
- 筛选条件
- </Typography.Title>
- </header>
- <Form layout="vertical" onFinish={() => void handleSubmit()} className="filter-form">
- <div className="filter-row second-row element-demand-filter-row">
- {mode === "period" ? (
- <Form.Item label={periodDaysLabel}>
- <InputNumber
- min={0}
- precision={0}
- value={periodDays}
- onChange={(v) => setPeriodDays(v ?? 7)}
- />
- </Form.Item>
- ) : null}
- <Form.Item label="当日分发曝光PV限制">
- <InputNumber
- min={0}
- precision={0}
- value={viewPvCount}
- onChange={(v) => setViewPvCount(v ?? 0)}
- />
- </Form.Item>
- {mode === "monthly" ? (
- <Form.Item label="月累计分发曝光PV阈值">
- <InputNumber
- min={0}
- precision={0}
- value={monthTotalPvThreshold}
- onChange={(v) => setMonthTotalPvThreshold(v ?? 0)}
- />
- </Form.Item>
- ) : null}
- <Form.Item label="贡献分限制">
- <InputNumber
- min={0}
- step={0.01}
- value={minContributionScore}
- onChange={(v) => setMinContributionScore(v ?? 0)}
- />
- </Form.Item>
- <Form.Item
- label={
- mode === "monthly" ? "累加平均ROV限制" : "平均ROV限制"
- }
- >
- <InputNumber
- min={0}
- step={0.001}
- value={rovAvg}
- onChange={(v) => setRovAvg(v ?? 0)}
- />
- </Form.Item>
- {mode === "monthly" ? (
- <Form.Item label="元素频次限制(有效月份数)">
- <InputNumber
- min={0}
- precision={0}
- value={minFrequency}
- onChange={(v) => setMinFrequency(v ?? 0)}
- />
- </Form.Item>
- ) : null}
- <Form.Item label=" ">
- <Button type="primary" htmlType="submit" loading={loading}>
- 查询
- </Button>
- </Form.Item>
- <Form.Item label=" ">
- <Button type="default" onClick={resetDefaults}>
- 恢复默认
- </Button>
- </Form.Item>
- </div>
- </Form>
- {error ? (
- <Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
- ) : null}
- </section>
- <section className="panel-section panel-section--table">
- <div className="table-toolbar">
- <Typography.Title level={5} className="panel-section-title panel-section-title--inline">
- {tableDetailTitle}
- </Typography.Title>
- <Space size={8} wrap className="table-toolbar-meta">
- <span className="meta-chip">本地 {total} 条</span>
- <span className="meta-chip">
- 第 {page} / {totalPages} 页
- </span>
- <Button
- type="default"
- loading={exporting}
- disabled={loading}
- onClick={() => void handleExport()}
- >
- 导出 Excel
- </Button>
- </Space>
- </div>
- {loading && !hasLoaded ? (
- <Skeleton active paragraph={{ rows: 10 }} />
- ) : (
- <div className="table-wrap">
- <Table
- rowKey={(record) =>
- String(record.demand_id ?? "").trim() ||
- `${record.strategy ?? ""}|${record.demand_name ?? ""}`
- }
- loading={loading}
- columns={columns}
- dataSource={pagedItems}
- pagination={false}
- scroll={{ x: mode === "monthly" ? 1150 : 880 }}
- rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
- />
- </div>
- )}
- <Modal
- title={`视频列表${videoModalTitleName ? ` — ${videoModalTitleName}` : ""}`}
- open={videoModalOpen}
- onCancel={() => setVideoModalOpen(false)}
- footer={
- <Button type="primary" onClick={() => setVideoModalOpen(false)}>
- 关闭
- </Button>
- }
- width={900}
- destroyOnHidden
- >
- {videoModalIds.length > 0 ? (
- <List
- size="small"
- bordered
- dataSource={videoModalIds}
- style={{ maxHeight: 480, overflow: "auto" }}
- renderItem={(vid, idx) => (
- <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
- <div
- style={{
- display: "flex",
- alignItems: "center",
- width: "100%",
- flexWrap: "nowrap",
- gap: 12,
- }}
- >
- <span
- style={{
- display: "inline-flex",
- alignItems: "baseline",
- gap: "0.25em",
- whiteSpace: "nowrap",
- minWidth: 0,
- }}
- >
- <Typography.Text type="secondary" style={{ margin: 0 }}>
- {idx + 1}.
- </Typography.Text>
- <Typography.Text copyable={{ text: vid }} style={{ margin: 0 }}>
- {vid}
- </Typography.Text>
- </span>
- <Space size={4} style={{ marginLeft: "auto", flexShrink: 0 }}>
- <Button
- type="link"
- size="small"
- onClick={() => openVideoCmsDetail(vid)}
- >
- 查看视频详情
- </Button>
- <Button
- type="link"
- size="small"
- loading={decodeLoadingVid === vid}
- onClick={() => void openVideoDecodePage(vid)}
- >
- 查看视频解构
- </Button>
- </Space>
- </div>
- </List.Item>
- )}
- />
- ) : (
- <Typography.Paragraph
- copyable={{ text: videoModalRaw }}
- style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
- >
- {videoModalRaw || "(空)"}
- </Typography.Paragraph>
- )}
- </Modal>
- <Modal
- title={`月份列表${monthModalTitleName ? ` — ${monthModalTitleName}` : ""}`}
- open={monthModalOpen}
- onCancel={() => setMonthModalOpen(false)}
- footer={
- <Button type="primary" onClick={() => setMonthModalOpen(false)}>
- 关闭
- </Button>
- }
- width={560}
- destroyOnHidden
- >
- {monthModalMonths.length > 0 ? (
- <List
- size="small"
- bordered
- dataSource={monthModalMonths}
- style={{ maxHeight: 480, overflow: "auto" }}
- renderItem={(ym, idx) => (
- <List.Item style={{ paddingBlock: 10, paddingInline: 16 }}>
- <Typography.Text copyable={{ text: ym }}>
- {idx + 1}. {formatYmDisplay(ym)}
- </Typography.Text>
- </List.Item>
- )}
- />
- ) : (
- <Typography.Paragraph
- copyable={{ text: monthModalRaw }}
- style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
- >
- {monthModalRaw || "(空)"}
- </Typography.Paragraph>
- )}
- </Modal>
- <div className="panel-footer">
- <Pagination
- current={page}
- total={total}
- pageSize={pageSize}
- showSizeChanger
- pageSizeOptions={["10", "20", "50", "100"]}
- showQuickJumper
- showTotal={(t) => `共 ${t} 条`}
- onChange={(nextPage, size) => {
- const nextSize = size ?? pageSize;
- if (nextSize !== pageSize) {
- setPageSize(nextSize);
- setPage(1);
- return;
- }
- setPage(nextPage);
- }}
- onShowSizeChange={(_, size) => {
- setPageSize(size);
- setPage(1);
- }}
- />
- </div>
- </section>
- </div>
- );
- }
- function SolarCalendarPanel({ active }: { active: boolean }) {
- return (
- <ElementDemandQueryPanel
- active={active}
- apiPath="element-demands/solar-calendar"
- periodDaysLabel="区间天数(含去年阳历今日)"
- tableDetailTitle="去年同期阳历特征点明细"
- />
- );
- }
- function LunarCalendarPanel({ active }: { active: boolean }) {
- return (
- <ElementDemandQueryPanel
- active={active}
- apiPath="element-demands/lunar-calendar"
- periodDaysLabel="区间天数(含去年阴历今日)"
- tableDetailTitle="去年同期阴历特征点明细"
- />
- );
- }
- function MonthlyDemandPanel({ active }: { active: boolean }) {
- return (
- <ElementDemandQueryPanel
- active={active}
- apiPath="element-demands/monthly"
- periodDaysLabel=""
- tableDetailTitle="逐月特征点明细"
- mode="monthly"
- />
- );
- }
- function App() {
- const [activeTab, setActiveTab] = useState("demand-pool");
- const tabItems = useMemo(
- () => [
- {
- key: "demand-pool",
- label: "需求池",
- children: <DemandPoolPanel />,
- },
- {
- key: "solar-calendar",
- label: "去年同期阳历特征点查询",
- children: (
- <SolarCalendarPanel active={activeTab === "solar-calendar"} />
- ),
- },
- {
- key: "lunar-calendar",
- label: "去年同期阴历特征点查询",
- children: (
- <LunarCalendarPanel active={activeTab === "lunar-calendar"} />
- ),
- },
- {
- key: "monthly-demand",
- label: "逐月特征点查询",
- children: (
- <MonthlyDemandPanel active={activeTab === "monthly-demand"} />
- ),
- },
- ],
- [activeTab],
- );
- return (
- <div className="page">
- <div className="hero">
- <Typography.Title level={2} className="hero-title">
- 需求池数据看板
- </Typography.Title>
- <div className="hero-subtitle">
- <Tag color="blue">数据检索</Tag>
- <Tag color="cyan">策略筛选</Tag>
- <Tag color="geekblue">日期范围分析</Tag>
- <Tag color="purple">需求筛选</Tag>
- </div>
- </div>
- <div className="dashboard-shell">
- <Tabs
- className="main-tabs demand-nav-tabs"
- activeKey={activeTab}
- onChange={setActiveTab}
- tabBarGutter={16}
- items={tabItems}
- />
- </div>
- </div>
- );
- }
- export default App;
|