|
|
@@ -1,16 +1,19 @@
|
|
|
-import { useEffect, useMemo, useState } from "react";
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
import {
|
|
|
Alert,
|
|
|
Button,
|
|
|
- Card,
|
|
|
DatePicker,
|
|
|
Form,
|
|
|
+ Input,
|
|
|
InputNumber,
|
|
|
+ List,
|
|
|
+ Modal,
|
|
|
Pagination,
|
|
|
Select,
|
|
|
Skeleton,
|
|
|
Space,
|
|
|
Table,
|
|
|
+ Tabs,
|
|
|
Tag,
|
|
|
Typography,
|
|
|
} from "antd";
|
|
|
@@ -44,6 +47,36 @@ 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;
|
|
|
+ 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 [];
|
|
|
+}
|
|
|
+
|
|
|
const API_BASE_URL =
|
|
|
import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
|
|
|
|
|
|
@@ -54,7 +87,7 @@ const getResolvedApiBaseUrl = () => {
|
|
|
return new URL(API_BASE_URL, window.location.origin).toString();
|
|
|
};
|
|
|
|
|
|
-function App() {
|
|
|
+function DemandPoolPanel() {
|
|
|
const [strategies, setStrategies] = useState<string[]>([]);
|
|
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
|
|
|
const today = dayjs();
|
|
|
@@ -68,6 +101,8 @@ function App() {
|
|
|
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);
|
|
|
@@ -93,6 +128,7 @@ function App() {
|
|
|
endDate,
|
|
|
appliedMinWeight,
|
|
|
appliedMaxWeight,
|
|
|
+ appliedDemandName,
|
|
|
sortBy,
|
|
|
sortOrder,
|
|
|
currentPage,
|
|
|
@@ -121,6 +157,10 @@ function App() {
|
|
|
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));
|
|
|
@@ -221,6 +261,7 @@ function App() {
|
|
|
}
|
|
|
setAppliedMinWeight(minWeightInput);
|
|
|
setAppliedMaxWeight(maxWeightInput);
|
|
|
+ setAppliedDemandName(demandNameInput.trim());
|
|
|
setCurrentPage(1);
|
|
|
setRefreshTick((value) => value + 1);
|
|
|
};
|
|
|
@@ -240,6 +281,8 @@ function App() {
|
|
|
setMaxWeightInput(null);
|
|
|
setAppliedMinWeight(null);
|
|
|
setAppliedMaxWeight(null);
|
|
|
+ setDemandNameInput("");
|
|
|
+ setAppliedDemandName("");
|
|
|
setSortBy("weight");
|
|
|
setSortOrder("desc");
|
|
|
setCurrentPage(1);
|
|
|
@@ -298,19 +341,14 @@ function App() {
|
|
|
);
|
|
|
|
|
|
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>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Card className="filter-card">
|
|
|
+ <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">
|
|
|
@@ -358,6 +396,14 @@ function App() {
|
|
|
</Space>
|
|
|
</Form.Item>
|
|
|
|
|
|
+ <Form.Item label="需求名称">
|
|
|
+ <Input
|
|
|
+ allowClear
|
|
|
+ value={demandNameInput}
|
|
|
+ onChange={(e) => setDemandNameInput(e.target.value)}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
<Form.Item label=" ">
|
|
|
<Button
|
|
|
type="primary"
|
|
|
@@ -371,7 +417,7 @@ function App() {
|
|
|
|
|
|
<Form.Item label=" ">
|
|
|
<Space>
|
|
|
- <Button type="dashed" onClick={resetFilters}>
|
|
|
+ <Button type="default" onClick={resetFilters}>
|
|
|
重置
|
|
|
</Button>
|
|
|
</Space>
|
|
|
@@ -398,50 +444,53 @@ function App() {
|
|
|
{error ? (
|
|
|
<Alert style={{ marginTop: 12 }} type="error" showIcon message={`请求失败: ${error}`} />
|
|
|
) : null}
|
|
|
- </Card>
|
|
|
+ </section>
|
|
|
|
|
|
- <Card className="table-card">
|
|
|
- <div className="table-title-row">
|
|
|
- <Typography.Text strong>需求明细</Typography.Text>
|
|
|
+ <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>
|
|
|
+ </Space>
|
|
|
</div>
|
|
|
{loading && !hasLoaded ? (
|
|
|
<Skeleton active paragraph={{ rows: 10 }} />
|
|
|
) : (
|
|
|
- <Table
|
|
|
- rowKey="id"
|
|
|
- loading={loading}
|
|
|
- columns={columns}
|
|
|
- dataSource={data.items}
|
|
|
- pagination={false}
|
|
|
- scroll={{ x: 920 }}
|
|
|
- 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");
|
|
|
+ <div className="table-wrap">
|
|
|
+ <Table
|
|
|
+ rowKey="id"
|
|
|
+ loading={loading}
|
|
|
+ columns={columns}
|
|
|
+ dataSource={data.items}
|
|
|
+ pagination={false}
|
|
|
+ scroll={{ x: 920 }}
|
|
|
+ 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);
|
|
|
- return;
|
|
|
- }
|
|
|
- setSortBy(nextField);
|
|
|
- setSortOrder(nextOrder === "ascend" ? "asc" : "desc");
|
|
|
- setCurrentPage(1);
|
|
|
- }}
|
|
|
- />
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
)}
|
|
|
- <div className="footer-bar">
|
|
|
- <Space size={10} wrap>
|
|
|
- <span className="pill">已选策略:{strategies.length}</span>
|
|
|
- <span className="pill">总条数:{data.total}</span>
|
|
|
- <span className="pill">
|
|
|
- 页码:{currentPage} / {totalPages}
|
|
|
- </span>
|
|
|
- </Space>
|
|
|
-
|
|
|
+ <div className="panel-footer">
|
|
|
<Pagination
|
|
|
current={currentPage}
|
|
|
total={data.total}
|
|
|
@@ -463,7 +512,465 @@ function App() {
|
|
|
}}
|
|
|
/>
|
|
|
</div>
|
|
|
- </Card>
|
|
|
+ </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 [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 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 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(
|
|
|
+ () => [
|
|
|
+ {
|
|
|
+ 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)),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "视频数",
|
|
|
+ dataIndex: "video_count",
|
|
|
+ width: 100,
|
|
|
+ render: (v) => v ?? "-",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
+ 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="累加平均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>
|
|
|
+ </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: 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={720}
|
|
|
+ destroyOnHidden
|
|
|
+ >
|
|
|
+ {videoModalIds.length > 0 ? (
|
|
|
+ <List
|
|
|
+ size="small"
|
|
|
+ bordered
|
|
|
+ dataSource={videoModalIds}
|
|
|
+ style={{ maxHeight: 480, overflow: "auto" }}
|
|
|
+ renderItem={(vid, idx) => (
|
|
|
+ <List.Item>
|
|
|
+ <Typography.Text type="secondary" style={{ marginRight: 8 }}>
|
|
|
+ {idx + 1}.
|
|
|
+ </Typography.Text>
|
|
|
+ <Typography.Text copyable>{vid}</Typography.Text>
|
|
|
+ </List.Item>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <Typography.Paragraph
|
|
|
+ copyable={{ text: videoModalRaw }}
|
|
|
+ style={{ marginBottom: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}
|
|
|
+ >
|
|
|
+ {videoModalRaw || "(空)"}
|
|
|
+ </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>
|
|
|
);
|
|
|
}
|