fix: reset pagination when switching operator market category filters (#205)

This commit is contained in:
Kecheng Sha
2025-12-29 15:16:33 +08:00
committed by GitHub
parent 081abf7d2f
commit e22f16166c
2 changed files with 461 additions and 451 deletions

View File

@@ -1,236 +1,238 @@
// 首页数据获取 // 首页数据获取
// 支持轮询功能,使用示例: // 支持轮询功能,使用示例:
// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData( // const { fetchData, startPolling, stopPolling, isPolling } = useFetchData(
// fetchFunction, // fetchFunction,
// mapFunction, // mapFunction,
// 5000, // 5秒轮询一次,默认30秒 // 5000, // 5秒轮询一次,默认30秒
// true, // 是否自动开始轮询,默认 true // true, // 是否自动开始轮询,默认 true
// [fetchStatistics, fetchOtherData] // 额外的轮询函数数组 // [fetchStatistics, fetchOtherData] // 额外的轮询函数数组
// ); // );
// //
// startPolling(); // 开始轮询 // startPolling(); // 开始轮询
// stopPolling(); // 停止轮询 // stopPolling(); // 停止轮询
// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时 // 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时
// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数 // 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useDebouncedEffect } from "./useDebouncedEffect"; import { useDebouncedEffect } from "./useDebouncedEffect";
import Loading from "@/utils/loading"; import Loading from "@/utils/loading";
import { App } from "antd"; import { App } from "antd";
export default function useFetchData<T>( export default function useFetchData<T>(
fetchFunc: (params?: any) => Promise<any>, fetchFunc: (params?: any) => Promise<any>,
mapDataFunc: (data: Partial<T>) => T = (data) => data as T, mapDataFunc: (data: Partial<T>) => T = (data) => data as T,
pollingInterval: number = 30000, // 默认30秒轮询一次 pollingInterval: number = 30000, // 默认30秒轮询一次
autoRefresh: boolean = false, // 是否自动开始轮询,默认 false autoRefresh: boolean = false, // 是否自动开始轮询,默认 false
additionalPollingFuncs: (() => Promise<any>)[] = [], // 额外的轮询函数 additionalPollingFuncs: (() => Promise<any>)[] = [], // 额外的轮询函数
pageOffset: number = 1 pageOffset: number = 1
) { ) {
const { message } = App.useApp(); const { message } = App.useApp();
// 轮询相关状态 // 轮询相关状态
const [isPolling, setIsPolling] = useState(false); const [isPolling, setIsPolling] = useState(false);
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null); const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
// 表格数据 // 表格数据
const [tableData, setTableData] = useState<T[]>([]); const [tableData, setTableData] = useState<T[]>([]);
// 设置加载状态 // 设置加载状态
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 搜索参数 // 搜索参数
const [searchParams, setSearchParams] = useState({ const [searchParams, setSearchParams] = useState({
keyword: "", keyword: "",
filter: { filter: {
type: [] as string[], type: [] as string[],
status: [] as string[], status: [] as string[],
tags: [] as string[], tags: [] as string[],
}, // 通用分类筛选(如算子市场的分类 ID 列表)
current: 1, categories: [] as string[],
pageSize: 12, },
}); current: 1,
pageSize: 12,
// 分页配置 });
const [pagination, setPagination] = useState({
total: 0, // 分页配置
showSizeChanger: true, const [pagination, setPagination] = useState({
pageSizeOptions: ["12", "24", "48"], total: 0,
showTotal: (total: number) => `${total}`, showSizeChanger: true,
onChange: (current: number, pageSize?: number) => { pageSizeOptions: ["12", "24", "48"],
setSearchParams((prev) => ({ showTotal: (total: number) => `${total}`,
...prev, onChange: (current: number, pageSize?: number) => {
current, setSearchParams((prev) => ({
pageSize: pageSize || prev.pageSize, ...prev,
})); current,
}, pageSize: pageSize || prev.pageSize,
}); }));
},
const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => { });
setSearchParams({
...searchParams, const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => {
current: 1, setSearchParams({
filter: { ...searchParams.filter, ...searchFilters }, ...searchParams,
}); current: 1,
}; filter: { ...searchParams.filter, ...searchFilters },
});
const handleKeywordChange = (keyword: string) => { };
setSearchParams({
...searchParams, const handleKeywordChange = (keyword: string) => {
current: 1, setSearchParams({
keyword: keyword, ...searchParams,
}); current: 1,
}; keyword: keyword,
});
function getFirstOfArray(arr: string[]) { };
if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined;
if (arr[0] === "all") return undefined; function getFirstOfArray(arr: string[]) {
return arr[0]; if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined;
} if (arr[0] === "all") return undefined;
return arr[0];
// 清除轮询定时器 }
const clearPollingTimer = useCallback(() => {
if (pollingTimerRef.current) { // 清除轮询定时器
clearTimeout(pollingTimerRef.current); const clearPollingTimer = useCallback(() => {
pollingTimerRef.current = null; if (pollingTimerRef.current) {
} clearTimeout(pollingTimerRef.current);
}, []); pollingTimerRef.current = null;
}
const fetchData = useCallback( }, []);
async (extraParams = {}, skipPollingRestart = false) => {
const { keyword, filter, current, pageSize } = searchParams; const fetchData = useCallback(
if (!skipPollingRestart) { async (extraParams = {}, skipPollingRestart = false) => {
Loading.show(); const { keyword, filter, current, pageSize } = searchParams;
setLoading(true); if (!skipPollingRestart) {
} Loading.show();
setLoading(true);
// 如果正在轮询且不是轮询触发的调用,先停止当前轮询 }
const wasPolling = isPolling && !skipPollingRestart;
if (wasPolling) { // 如果正在轮询且不是轮询触发的调用,先停止当前轮询
clearPollingTimer(); const wasPolling = isPolling && !skipPollingRestart;
} if (wasPolling) {
clearPollingTimer();
try { }
// 同时执行主要数据获取和额外的轮询函数
const promises = [ try {
fetchFunc({ // 同时执行主要数据获取和额外的轮询函数
...Object.fromEntries( const promises = [
Object.entries(filter).filter(([_, value]) => value != null && value.length > 0) fetchFunc({
), ...Object.fromEntries(
...extraParams, Object.entries(filter).filter(([_, value]) => value != null && value.length > 0)
keyword, ),
type: getFirstOfArray(filter?.type) || undefined, ...extraParams,
status: getFirstOfArray(filter?.status) || undefined, keyword,
tags: filter?.tags?.length ? filter.tags.join(",") : undefined, type: getFirstOfArray(filter?.type) || undefined,
page: current - pageOffset, status: getFirstOfArray(filter?.status) || undefined,
size: pageSize, // Use camelCase for HTTP query params tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
}), page: current - pageOffset,
...additionalPollingFuncs.map((func) => func()), size: pageSize, // Use camelCase for HTTP query params
]; }),
...additionalPollingFuncs.map((func) => func()),
const results = await Promise.all(promises); ];
const { data } = results[0]; // 主要数据结果
const results = await Promise.all(promises);
setPagination((prev) => ({ const { data } = results[0]; // 主要数据结果
...prev,
total: data?.totalElements || 0, setPagination((prev) => ({
})); ...prev,
let result = []; total: data?.totalElements || 0,
if (mapDataFunc) { }));
result = data?.content.map(mapDataFunc) ?? []; let result = [];
} if (mapDataFunc) {
setTableData(result); result = data?.content.map(mapDataFunc) ?? [];
}
// 如果之前正在轮询且不是轮询触发的调用,重新开始轮询 setTableData(result);
if (wasPolling) {
const poll = () => { // 如果之前正在轮询且不是轮询触发的调用,重新开始轮询
pollingTimerRef.current = setTimeout(() => { if (wasPolling) {
fetchData({}, true).then(() => { const poll = () => {
if (pollingTimerRef.current) { pollingTimerRef.current = setTimeout(() => {
poll(); fetchData({}, true).then(() => {
} if (pollingTimerRef.current) {
}); poll();
}, pollingInterval); }
}; });
poll(); }, pollingInterval);
} };
} catch (error) { poll();
console.error(error); }
message.error("数据获取失败,请稍后重试"); } catch (error) {
} finally { console.error(error);
Loading.hide(); message.error("数据获取失败,请稍后重试");
setLoading(false); } finally {
} Loading.hide();
}, setLoading(false);
[ }
searchParams, },
fetchFunc, [
mapDataFunc, searchParams,
isPolling, fetchFunc,
clearPollingTimer, mapDataFunc,
pollingInterval, isPolling,
message, clearPollingTimer,
additionalPollingFuncs, pollingInterval,
] message,
); additionalPollingFuncs,
]
// 开始轮询 );
const startPolling = useCallback(() => {
clearPollingTimer(); // 开始轮询
setIsPolling(true); const startPolling = useCallback(() => {
clearPollingTimer();
const poll = () => { setIsPolling(true);
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => { const poll = () => {
if (pollingTimerRef.current) { pollingTimerRef.current = setTimeout(() => {
poll(); fetchData({}, true).then(() => {
} if (pollingTimerRef.current) {
}); poll();
}, pollingInterval); }
}; });
}, pollingInterval);
poll(); };
}, [pollingInterval, clearPollingTimer, fetchData]);
poll();
// 停止轮询 }, [pollingInterval, clearPollingTimer, fetchData]);
const stopPolling = useCallback(() => {
clearPollingTimer(); // 停止轮询
setIsPolling(false); const stopPolling = useCallback(() => {
}, [clearPollingTimer]); clearPollingTimer();
setIsPolling(false);
// 搜索参数变化时,自动刷新数据 }, [clearPollingTimer]);
// keyword 变化时,防抖500ms后刷新
useDebouncedEffect( // 搜索参数变化时,自动刷新数据
() => { // keyword 变化时,防抖500ms后刷新
fetchData(); useDebouncedEffect(
}, () => {
[searchParams], fetchData();
searchParams?.keyword ? 500 : 0 },
); [searchParams],
searchParams?.keyword ? 500 : 0
// 组件卸载时清理轮询 );
useEffect(() => {
if (autoRefresh) { // 组件卸载时清理轮询
startPolling(); useEffect(() => {
} if (autoRefresh) {
return () => { startPolling();
clearPollingTimer(); }
}; return () => {
}, [clearPollingTimer]); clearPollingTimer();
};
return { }, [clearPollingTimer]);
loading,
tableData, return {
pagination: { loading,
...pagination, tableData,
current: searchParams.current, pagination: {
pageSize: searchParams.pageSize, ...pagination,
}, current: searchParams.current,
searchParams, pageSize: searchParams.pageSize,
setSearchParams, },
setPagination, searchParams,
handleFiltersChange, setSearchParams,
handleKeywordChange, setPagination,
fetchData, handleFiltersChange,
isPolling, handleKeywordChange,
startPolling, fetchData,
stopPolling, isPolling,
}; startPolling,
} stopPolling,
};
}

View File

@@ -1,215 +1,223 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, message } from "antd"; import { Button, message } from "antd";
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FilterOutlined, FilterOutlined,
PlusOutlined, PlusOutlined,
DownloadOutlined DownloadOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Boxes } from "lucide-react"; import { Boxes } from "lucide-react";
import { SearchControls } from "@/components/SearchControls"; import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView"; import CardView from "@/components/CardView";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { import type {
CategoryTreeI, CategoryTreeI,
OperatorI, OperatorI,
} from "@/pages/OperatorMarket/operator.model"; } from "@/pages/OperatorMarket/operator.model";
import Filters from "./components/Filters"; import Filters from "./components/Filters";
import TagManagement from "@/components/business/TagManagement"; import TagManagement from "@/components/business/TagManagement";
import { ListView } from "./components/List"; import { ListView } from "./components/List";
import useFetchData from "@/hooks/useFetchData"; import useFetchData from "@/hooks/useFetchData";
import { import {
deleteOperatorByIdUsingDelete, deleteOperatorByIdUsingDelete,
downloadExampleOperatorUsingGet, downloadExampleOperatorUsingGet,
queryCategoryTreeUsingGet, queryCategoryTreeUsingGet,
queryOperatorsUsingPost, queryOperatorsUsingPost,
} from "../operator.api"; } from "../operator.api";
import { mapOperator } from "../operator.const"; import { mapOperator } from "../operator.const";
export default function OperatorMarketPage() { export default function OperatorMarketPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [viewMode, setViewMode] = useState<"card" | "list">("card"); const [viewMode, setViewMode] = useState<"card" | "list">("card");
const [selectedFilters, setSelectedFilters] = useState< const [selectedFilters, setSelectedFilters] = useState<
Record<string, string[]> Record<string, string[]>
>({}); >({});
const [showFilters, setShowFilters] = useState(true); const [showFilters, setShowFilters] = useState(true);
const [categoriesTree, setCategoriesTree] = useState<CategoryTreeI[]>([]); const [categoriesTree, setCategoriesTree] = useState<CategoryTreeI[]>([]);
const initCategoriesTree = async () => { const initCategoriesTree = async () => {
const { data } = await queryCategoryTreeUsingGet({ page: 0, size: 1000 }); const { data } = await queryCategoryTreeUsingGet({ page: 0, size: 1000 });
setCategoriesTree(data.content || []); setCategoriesTree(data.content || []);
}; };
useEffect(() => { useEffect(() => {
initCategoriesTree(); initCategoriesTree();
}, []); }, []);
const { const {
tableData, tableData,
pagination, pagination,
searchParams, searchParams,
fetchData, setSearchParams,
handleFiltersChange, fetchData,
handleKeywordChange, handleFiltersChange,
} = useFetchData(queryOperatorsUsingPost, mapOperator); handleKeywordChange,
} = useFetchData(queryOperatorsUsingPost, mapOperator);
const handleUploadOperator = () => {
navigate(`/data/operator-market/create`); const handleUploadOperator = () => {
}; navigate(`/data/operator-market/create`);
};
const handleDownload = async () => {
await downloadExampleOperatorUsingGet("test_operator.tar"); const handleDownload = async () => {
message.success("文件下载成功"); await downloadExampleOperatorUsingGet("test_operator.tar");
}; message.success("文件下载成功");
};
const handleUpdateOperator = (operator: OperatorI) => {
navigate(`/data/operator-market/create/${operator.id}`); const handleUpdateOperator = (operator: OperatorI) => {
}; navigate(`/data/operator-market/create/${operator.id}`);
};
const handleDeleteOperator = async (operator: OperatorI) => {
try { const handleDeleteOperator = async (operator: OperatorI) => {
await deleteOperatorByIdUsingDelete(operator.id); try {
message.success("算子删除成功"); await deleteOperatorByIdUsingDelete(operator.id);
fetchData(); message.success("算子删除成功");
} catch (error) { fetchData();
message.error("算子删除失败"); } catch (error) {
} message.error("算子删除失败");
}; }
};
const operations = [
{ const operations = [
key: "edit", {
label: "更新", key: "edit",
icon: <EditOutlined />, label: "更新",
onClick: handleUpdateOperator, icon: <EditOutlined />,
}, onClick: handleUpdateOperator,
{ },
key: "delete", {
label: "删除", key: "delete",
danger: true, label: "删除",
icon: <DeleteOutlined />, danger: true,
confirm: { icon: <DeleteOutlined />,
title: "确认删除", confirm: {
description: "此操作不可撤销,是否继续?", title: "确认删除",
okText: "删除", description: "此操作不可撤销,是否继续?",
okType: "danger", okText: "删除",
cancelText: "取消", okType: "danger",
}, cancelText: "取消",
onClick: handleDeleteOperator, },
}, onClick: handleDeleteOperator,
]; },
];
useEffect(() => {
if (Object.keys(selectedFilters).length === 0) { useEffect(() => {
return; const filteredIds = Object.values(selectedFilters).reduce(
} (acc, filter: string[]) => {
const filteredIds = Object.values(selectedFilters).reduce( if (filter.length) {
(acc, filter: string[]) => { acc.push(...filter);
if (filter.length) { }
acc.push(...filter);
} return acc;
},
return acc; []
}, );
[]
); // 分类筛选变化时:
// 1. 将分类 ID 写入通用 searchParams.filter.categories,确保分页时条件不会丢失
fetchData({ categories: filteredIds?.length ? filteredIds : undefined }); // 2. 将页码重置为 1,避免从“全选”页的当前页跳入细分列表的同一页
}, [selectedFilters]); setSearchParams((prev) => ({
...prev,
return ( current: 1,
<div className="h-full flex flex-col gap-4"> filter: {
{/* Header */} ...prev.filter,
<div className="flex justify-between"> categories: filteredIds,
<h1 className="text-xl font-bold text-gray-900"></h1> },
<div className="flex gap-2"> }));
{/*<TagManagement />*/} }, [selectedFilters, setSearchParams]);
<Button
icon={<DownloadOutlined />} return (
onClick={handleDownload} <div className="h-full flex flex-col gap-4">
> {/* Header */}
<div className="flex justify-between">
</Button> <h1 className="text-xl font-bold text-gray-900"></h1>
<Button <div className="flex gap-2">
type="primary" {/*<TagManagement />*/}
icon={<PlusOutlined />} <Button
onClick={handleUploadOperator} icon={<DownloadOutlined />}
> onClick={handleDownload}
>
</Button>
</div> </Button>
</div> <Button
{/* Main Content */} type="primary"
<div className="flex-overflow-auto flex-row border-card"> icon={<PlusOutlined />}
<div onClick={handleUploadOperator}
className={`border-r border-gray-100 transition-all duration-300 ${ >
showFilters
? "translate-x-0 w-56" </Button>
: "-translate-x-full w-0 opacity-0" </div>
}`} </div>
> {/* Main Content */}
<Filters <div className="flex-overflow-auto flex-row border-card">
hideFilter={() => setShowFilters(false)} <div
categoriesTree={categoriesTree} className={`border-r border-gray-100 transition-all duration-300 ${
selectedFilters={selectedFilters} showFilters
setSelectedFilters={setSelectedFilters} ? "translate-x-0 w-56"
/> : "-translate-x-full w-0 opacity-0"
</div> }`}
<div className="flex-overflow-auto p-6 "> >
<div className="flex w-full items-top gap-4 border-b border-gray-200 mb-4"> <Filters
{!showFilters && ( hideFilter={() => setShowFilters(false)}
<Button categoriesTree={categoriesTree}
type="text" selectedFilters={selectedFilters}
icon={<FilterOutlined />} setSelectedFilters={setSelectedFilters}
onClick={() => setShowFilters(true)} />
/> </div>
)} <div className="flex-overflow-auto p-6 ">
<div className="flex-1 mb-4"> <div className="flex w-full items-top gap-4 border-b border-gray-200 mb-4">
<SearchControls {!showFilters && (
searchTerm={searchParams.keyword} <Button
onSearchChange={handleKeywordChange} type="text"
searchPlaceholder="搜索算子名称、描述..." icon={<FilterOutlined />}
filters={[]} onClick={() => setShowFilters(true)}
onFiltersChange={handleFiltersChange} />
viewMode={viewMode} )}
onViewModeChange={setViewMode} <div className="flex-1 mb-4">
showViewToggle={true} <SearchControls
onReload={fetchData} searchTerm={searchParams.keyword}
/> onSearchChange={handleKeywordChange}
</div> searchPlaceholder="搜索算子名称、描述..."
</div> filters={[]}
{/* Content */} onFiltersChange={handleFiltersChange}
{tableData.length === 0 ? ( viewMode={viewMode}
<div className="text-center py-12"> onViewModeChange={setViewMode}
<Boxes className="w-16 h-16 text-gray-300 mx-auto mb-4" /> showViewToggle={true}
<h3 className="text-lg font-medium text-gray-900 mb-2"> onReload={fetchData}
/>
</h3> </div>
<p className="text-gray-500"></p> </div>
</div> {/* Content */}
) : ( {tableData.length === 0 ? (
<> <div className="text-center py-12">
{viewMode === "card" ? ( <Boxes className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<CardView <h3 className="text-lg font-medium text-gray-900 mb-2">
data={tableData}
pagination={pagination} </h3>
operations={operations} <p className="text-gray-500"></p>
onView={(item) => navigate(`/data/operator-market/plugin-detail/${item.id}`)} </div>
/> ) : (
) : ( <>
<ListView {viewMode === "card" ? (
operators={tableData} <CardView
operations={operations} data={tableData}
pagination={pagination} pagination={pagination}
/> operations={operations}
)} onView={(item) => navigate(`/data/operator-market/plugin-detail/${item.id}`)}
</> />
)} ) : (
</div> <ListView
</div> operators={tableData}
</div> operations={operations}
); pagination={pagination}
} />
)}
</>
)}
</div>
</div>
</div>
);
}