|
|
@@ -177,10 +177,22 @@ function buildHotSourceViewUrl(params: HotSourceViewParams): string {
|
|
|
demand_type: params.demandType,
|
|
|
dt: params.dt,
|
|
|
});
|
|
|
- return `${window.location.pathname}?${search.toString()}`;
|
|
|
+ return `${window.location.origin}${window.location.pathname}?${search.toString()}`;
|
|
|
}
|
|
|
|
|
|
-function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSourceViewParams) => void }) {
|
|
|
+function openHotSourceViewInNewTab(params: HotSourceViewParams) {
|
|
|
+ window.open(buildHotSourceViewUrl(params), "_blank", "noopener,noreferrer");
|
|
|
+}
|
|
|
+
|
|
|
+function closeHotSourceView() {
|
|
|
+ if (window.opener && !window.opener.closed) {
|
|
|
+ window.close();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ window.location.href = window.location.pathname;
|
|
|
+}
|
|
|
+
|
|
|
+function DemandPoolPanel() {
|
|
|
const [strategies, setStrategies] = useState<string[]>([]);
|
|
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
|
|
|
const today = dayjs();
|
|
|
@@ -503,7 +515,7 @@ function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSou
|
|
|
type="link"
|
|
|
size="small"
|
|
|
onClick={() =>
|
|
|
- onViewHotSource({
|
|
|
+ openHotSourceViewInNewTab({
|
|
|
demandName,
|
|
|
demandType,
|
|
|
dt,
|
|
|
@@ -516,7 +528,7 @@ function DemandPoolPanel({ onViewHotSource }: { onViewHotSource: (params: HotSou
|
|
|
},
|
|
|
},
|
|
|
],
|
|
|
- [sortBy, sortOrder, onViewHotSource]
|
|
|
+ [sortBy, sortOrder]
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
@@ -1277,6 +1289,407 @@ function ElementDemandQueryPanel({
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+type HotContentDemandExportItem = {
|
|
|
+ id: number;
|
|
|
+ source: string;
|
|
|
+ hot_title: string;
|
|
|
+ point_category: string;
|
|
|
+ item_type: string;
|
|
|
+ item_type_label: string;
|
|
|
+ matched_demand: string;
|
|
|
+ is_as_demand: number;
|
|
|
+ is_as_demand_label: string;
|
|
|
+ contribution_score: number | null;
|
|
|
+ wxindex_keyword: string;
|
|
|
+ all_hot_keywords: string;
|
|
|
+ wxindex_latest_score: number;
|
|
|
+ wxindex_trend: string;
|
|
|
+ item_type: string;
|
|
|
+ record_created_at: string;
|
|
|
+};
|
|
|
+
|
|
|
+type HotContentDemandExportResponse = {
|
|
|
+ total: number;
|
|
|
+ page: number;
|
|
|
+ page_size: number;
|
|
|
+ items: HotContentDemandExportItem[];
|
|
|
+};
|
|
|
+
|
|
|
+type IsAsDemandFilter = "all" | "yes" | "no";
|
|
|
+type ItemTypeFilter = "all" | "word" | "point";
|
|
|
+
|
|
|
+function HotContentDemandExportPanel({ active }: { active: boolean }) {
|
|
|
+ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
|
|
|
+ const today = dayjs();
|
|
|
+ return [today, today];
|
|
|
+ });
|
|
|
+ const [isAsDemandFilter, setIsAsDemandFilter] = useState<IsAsDemandFilter>("all");
|
|
|
+ const [itemTypeFilter, setItemTypeFilter] = useState<ItemTypeFilter>("all");
|
|
|
+ const [appliedIsAsDemand, setAppliedIsAsDemand] = useState<IsAsDemandFilter>("all");
|
|
|
+ const [appliedItemType, setAppliedItemType] = useState<ItemTypeFilter>("all");
|
|
|
+ const [minWxindexInput, setMinWxindexInput] = useState<number | null>(null);
|
|
|
+ const [appliedMinWxindex, setAppliedMinWxindex] = useState<number | null>(null);
|
|
|
+ const [currentPage, setCurrentPage] = useState(1);
|
|
|
+ const [pageSize, setPageSize] = useState(20);
|
|
|
+ const [refreshTick, setRefreshTick] = useState(0);
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+ const [exporting, setExporting] = useState(false);
|
|
|
+ const [hasLoaded, setHasLoaded] = useState(false);
|
|
|
+ const [error, setError] = useState("");
|
|
|
+ const [data, setData] = useState<HotContentDemandExportResponse>({
|
|
|
+ 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 appendFiltersToUrl = (url: URL) => {
|
|
|
+ if (startDate) {
|
|
|
+ url.searchParams.set("start_dt", startDate);
|
|
|
+ }
|
|
|
+ if (endDate) {
|
|
|
+ url.searchParams.set("end_dt", endDate);
|
|
|
+ }
|
|
|
+ if (appliedIsAsDemand === "yes") {
|
|
|
+ url.searchParams.set("is_as_demand", "1");
|
|
|
+ } else if (appliedIsAsDemand === "no") {
|
|
|
+ url.searchParams.set("is_as_demand", "0");
|
|
|
+ }
|
|
|
+ if (appliedItemType === "word") {
|
|
|
+ url.searchParams.set("item_type", "词");
|
|
|
+ } else if (appliedItemType === "point") {
|
|
|
+ url.searchParams.set("item_type", "点");
|
|
|
+ }
|
|
|
+ if (appliedMinWxindex !== null) {
|
|
|
+ url.searchParams.set("min_wxindex_latest_score", String(appliedMinWxindex));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const buildRequestUrl = (page: number, size: number = pageSize) => {
|
|
|
+ const resolvedBase = getResolvedApiBaseUrl();
|
|
|
+ const baseWithSlash = resolvedBase.endsWith("/")
|
|
|
+ ? resolvedBase
|
|
|
+ : `${resolvedBase}/`;
|
|
|
+ const url = new URL("hot-content/demand-exports", baseWithSlash);
|
|
|
+ appendFiltersToUrl(url);
|
|
|
+ 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("hot-content/demand-exports/export", baseWithSlash);
|
|
|
+ appendFiltersToUrl(url);
|
|
|
+ return url.toString();
|
|
|
+ };
|
|
|
+
|
|
|
+ const queryKey = JSON.stringify({
|
|
|
+ startDate,
|
|
|
+ endDate,
|
|
|
+ appliedIsAsDemand,
|
|
|
+ appliedItemType,
|
|
|
+ appliedMinWxindex,
|
|
|
+ currentPage,
|
|
|
+ pageSize,
|
|
|
+ refreshTick,
|
|
|
+ active,
|
|
|
+ });
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ const detail = await response.text();
|
|
|
+ throw new Error(detail || `HTTP ${response.status}`);
|
|
|
+ }
|
|
|
+ const payload = (await response.json()) as HotContentDemandExportResponse;
|
|
|
+ setData(payload);
|
|
|
+ } catch (queryError) {
|
|
|
+ setError(
|
|
|
+ queryError instanceof Error ? queryError.message : "查询失败,请重试",
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ setHasLoaded(true);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!active || dateRangeInvalid) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ void fetchData(currentPage, pageSize);
|
|
|
+ }, [queryKey, dateRangeInvalid]);
|
|
|
+
|
|
|
+ const handleSubmit = () => {
|
|
|
+ if (dateRangeInvalid) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setAppliedIsAsDemand(isAsDemandFilter);
|
|
|
+ setAppliedItemType(itemTypeFilter);
|
|
|
+ setAppliedMinWxindex(minWxindexInput);
|
|
|
+ setCurrentPage(1);
|
|
|
+ setRefreshTick((value) => value + 1);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleExport = async () => {
|
|
|
+ if (dateRangeInvalid) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setExporting(true);
|
|
|
+ try {
|
|
|
+ await downloadExcelExport(buildExportUrl(), "新热事件查询.xlsx");
|
|
|
+ message.success("导出成功");
|
|
|
+ } catch {
|
|
|
+ message.error("导出失败,请重试");
|
|
|
+ } finally {
|
|
|
+ setExporting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const resetFilters = () => {
|
|
|
+ const today = dayjs();
|
|
|
+ setDateRange([today, today]);
|
|
|
+ setIsAsDemandFilter("all");
|
|
|
+ setItemTypeFilter("all");
|
|
|
+ setAppliedIsAsDemand("all");
|
|
|
+ setAppliedItemType("all");
|
|
|
+ setMinWxindexInput(null);
|
|
|
+ setAppliedMinWxindex(null);
|
|
|
+ setCurrentPage(1);
|
|
|
+ setRefreshTick((value) => value + 1);
|
|
|
+ };
|
|
|
+
|
|
|
+ const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
|
|
|
+
|
|
|
+ const columns: ColumnsType<HotContentDemandExportItem> = useMemo(
|
|
|
+ () => [
|
|
|
+ {
|
|
|
+ title: "来源",
|
|
|
+ dataIndex: "source",
|
|
|
+ width: 100,
|
|
|
+ ellipsis: true,
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "热点标题",
|
|
|
+ dataIndex: "hot_title",
|
|
|
+ width: 220,
|
|
|
+ ellipsis: true,
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "类型",
|
|
|
+ dataIndex: "point_category",
|
|
|
+ width: 100,
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "需求类型",
|
|
|
+ dataIndex: "item_type_label",
|
|
|
+ width: 90,
|
|
|
+ align: "center",
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "匹配需求",
|
|
|
+ dataIndex: "matched_demand",
|
|
|
+ width: 200,
|
|
|
+ ellipsis: true,
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "是否成为需求",
|
|
|
+ dataIndex: "is_as_demand_label",
|
|
|
+ width: 120,
|
|
|
+ align: "center",
|
|
|
+ fixed: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "创建时间",
|
|
|
+ dataIndex: "record_created_at",
|
|
|
+ width: 170,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "贡献分",
|
|
|
+ dataIndex: "contribution_score",
|
|
|
+ width: 100,
|
|
|
+ align: "right",
|
|
|
+ render: (v: number | null) =>
|
|
|
+ v === null || v === undefined ? "-" : Number(v).toFixed(2),
|
|
|
+ },
|
|
|
+ { title: "最高微信指数词", dataIndex: "wxindex_keyword", width: 160, ellipsis: true },
|
|
|
+ { title: "待选微信指数词", dataIndex: "all_hot_keywords", width: 200, ellipsis: true },
|
|
|
+ {
|
|
|
+ title: "微信指数热度",
|
|
|
+ dataIndex: "wxindex_latest_score",
|
|
|
+ width: 120,
|
|
|
+ align: "right",
|
|
|
+ render: (v: number) => Number(v ?? 0).toLocaleString(),
|
|
|
+ },
|
|
|
+ { title: "微信指数趋势", dataIndex: "wxindex_trend", width: 110 },
|
|
|
+ ],
|
|
|
+ [],
|
|
|
+ );
|
|
|
+
|
|
|
+ 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={handleSubmit} className="filter-form">
|
|
|
+ <div className="filter-row second-row hot-content-demand-filter-row">
|
|
|
+ <Form.Item label="日期区间(热点记录创建时间)">
|
|
|
+ <DatePicker.RangePicker
|
|
|
+ value={dateRange}
|
|
|
+ onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
|
|
|
+ allowClear={false}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label="是否是需求">
|
|
|
+ <Select<IsAsDemandFilter>
|
|
|
+ className="hot-content-filter-select"
|
|
|
+ value={isAsDemandFilter}
|
|
|
+ onChange={setIsAsDemandFilter}
|
|
|
+ options={[
|
|
|
+ { label: "全部", value: "all" },
|
|
|
+ { label: "是", value: "yes" },
|
|
|
+ { label: "否", value: "no" },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label="需求类型">
|
|
|
+ <Select<ItemTypeFilter>
|
|
|
+ className="hot-content-filter-select"
|
|
|
+ value={itemTypeFilter}
|
|
|
+ onChange={setItemTypeFilter}
|
|
|
+ options={[
|
|
|
+ { label: "全部", value: "all" },
|
|
|
+ { label: "特征点", value: "word" },
|
|
|
+ { label: "短语", value: "point" },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label="微信指数热度 ≥">
|
|
|
+ <InputNumber
|
|
|
+ className="hot-content-wxindex-input"
|
|
|
+ min={0}
|
|
|
+ step={10000}
|
|
|
+ placeholder="不限制"
|
|
|
+ value={minWxindexInput}
|
|
|
+ onChange={(v) => setMinWxindexInput(v ?? null)}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label=" ">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ htmlType="submit"
|
|
|
+ loading={loading}
|
|
|
+ disabled={dateRangeInvalid}
|
|
|
+ >
|
|
|
+ 查询
|
|
|
+ </Button>
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label=" ">
|
|
|
+ <Button type="default" onClick={resetFilters}>
|
|
|
+ 重置
|
|
|
+ </Button>
|
|
|
+ </Form.Item>
|
|
|
+ </div>
|
|
|
+ </Form>
|
|
|
+ {dateRangeInvalid ? (
|
|
|
+ <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">共 {data.total} 条</span>
|
|
|
+ <span className="meta-chip">
|
|
|
+ 第 {currentPage} / {totalPages} 页
|
|
|
+ </span>
|
|
|
+ <Button
|
|
|
+ type="default"
|
|
|
+ loading={exporting}
|
|
|
+ disabled={dateRangeInvalid || 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: 1690 }}
|
|
|
+ rowClassName={(_, index) => (index % 2 === 0 ? "row-even" : "row-odd")}
|
|
|
+ />
|
|
|
+ </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);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onShowSizeChange={(_, size) => {
|
|
|
+ setPageSize(size);
|
|
|
+ setCurrentPage(1);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function SolarCalendarPanel({ active }: { active: boolean }) {
|
|
|
return (
|
|
|
<ElementDemandQueryPanel
|
|
|
@@ -1325,23 +1738,12 @@ function App() {
|
|
|
return () => window.removeEventListener("popstate", onPopState);
|
|
|
}, []);
|
|
|
|
|
|
- const openHotSourceView = useCallback((params: HotSourceViewParams) => {
|
|
|
- const url = buildHotSourceViewUrl(params);
|
|
|
- window.history.pushState(null, "", url);
|
|
|
- setHotSourceView(params);
|
|
|
- }, []);
|
|
|
-
|
|
|
- const closeHotSourceView = useCallback(() => {
|
|
|
- window.history.pushState(null, "", window.location.pathname);
|
|
|
- setHotSourceView(null);
|
|
|
- }, []);
|
|
|
-
|
|
|
const tabItems = useMemo(
|
|
|
() => [
|
|
|
{
|
|
|
key: "demand-pool",
|
|
|
label: "需求池",
|
|
|
- children: <DemandPoolPanel onViewHotSource={openHotSourceView} />,
|
|
|
+ children: <DemandPoolPanel />,
|
|
|
},
|
|
|
{
|
|
|
key: "solar-calendar",
|
|
|
@@ -1364,8 +1766,15 @@ function App() {
|
|
|
<MonthlyDemandPanel active={activeTab === "monthly-demand"} />
|
|
|
),
|
|
|
},
|
|
|
+ {
|
|
|
+ key: "hot-content-demand",
|
|
|
+ label: "新热事件查询",
|
|
|
+ children: (
|
|
|
+ <HotContentDemandExportPanel active={activeTab === "hot-content-demand"} />
|
|
|
+ ),
|
|
|
+ },
|
|
|
],
|
|
|
- [activeTab, openHotSourceView],
|
|
|
+ [activeTab],
|
|
|
);
|
|
|
|
|
|
if (hotSourceView) {
|