Commit bd6eab3f by Finley Ge Committed by GitHub

perf(marketplace): virtualize plugin grid rendering (#7089)

parent 53dd682a
...@@ -40,6 +40,7 @@ const ToolDetailDrawer = ({ ...@@ -40,6 +40,7 @@ const ToolDetailDrawer = ({
systemTitle, systemTitle,
onFetchDetail, onFetchDetail,
onFetchVersions, onFetchVersions,
onVersionChange,
isLoading, isLoading,
showPoint, showPoint,
mode, mode,
...@@ -53,6 +54,7 @@ const ToolDetailDrawer = ({ ...@@ -53,6 +54,7 @@ const ToolDetailDrawer = ({
systemTitle?: string; systemTitle?: string;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>; onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>; onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
onVersionChange?: (version: string) => void;
isLoading?: boolean; isLoading?: boolean;
showPoint: boolean; showPoint: boolean;
mode: 'admin' | 'team' | 'marketplace'; mode: 'admin' | 'team' | 'marketplace';
...@@ -137,7 +139,10 @@ const ToolDetailDrawer = ({ ...@@ -137,7 +139,10 @@ const ToolDetailDrawer = ({
label: item.version, label: item.version,
description: item.versionDescription, description: item.versionDescription,
isActive: item.version === currentVersion, isActive: item.version === currentVersion,
onClick: () => setSelectedVersion(item.version) onClick: () => {
setSelectedVersion(item.version);
onVersionChange?.(item.version);
}
})) }))
} }
]} ]}
......
...@@ -10,6 +10,7 @@ import ToolTagFilterBox from '@fastgpt/web/components/core/plugin/tool/TagFilter ...@@ -10,6 +10,7 @@ import ToolTagFilterBox from '@fastgpt/web/components/core/plugin/tool/TagFilter
import ToolDetailDrawer from '@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer'; import ToolDetailDrawer from '@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { usePagination } from '@fastgpt/web/hooks/usePagination'; import { usePagination } from '@fastgpt/web/hooks/usePagination';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { import {
getDownloadURL, getDownloadURL,
...@@ -21,11 +22,38 @@ import { ...@@ -21,11 +22,38 @@ import {
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import I18nLngSelector from '@/web/common/Select/I18nLngSelector'; import I18nLngSelector from '@/web/common/Select/I18nLngSelector';
import Head from 'next/head'; import Head from 'next/head';
import {
buildMarketplacePageUrl,
getMarketplaceDetailQueryFromSearch,
getSingleQueryValue,
type MarketplaceDetailQuery
} from '@/web/query';
const getToolQuery = (tool: ToolCardItemType | null): MarketplaceDetailQuery | null =>
tool
? {
pluginId: tool.id,
version: tool.version
}
: null;
const createQuerySelectedTool = ({
pluginId,
version
}: Required<Pick<MarketplaceDetailQuery, 'pluginId'>> &
Pick<MarketplaceDetailQuery, 'version'>): ToolCardItemType => ({
id: pluginId,
name: pluginId,
description: '',
version
});
const ToolkitMarketplace = () => { const ToolkitMarketplace = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
const { search, tags } = router.query; const { search, tags, pluginId, version } = router.query;
const queryPluginId = getSingleQueryValue(pluginId);
const queryVersion = getSingleQueryValue(version);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
...@@ -33,6 +61,14 @@ const ToolkitMarketplace = () => { ...@@ -33,6 +61,14 @@ const ToolkitMarketplace = () => {
const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompactSearch, setShowCompactSearch] = useState(false); const [showCompactSearch, setShowCompactSearch] = useState(false);
const heroSectionRef = useRef<HTMLDivElement>(null); const heroSectionRef = useRef<HTMLDivElement>(null);
const detailQueryRef = useRef<MarketplaceDetailQuery | null>(
queryPluginId
? {
pluginId: queryPluginId,
version: queryVersion
}
: null
);
// 从 URL 初始化状态 // 从 URL 初始化状态
useEffect(() => { useEffect(() => {
...@@ -77,23 +113,15 @@ const ToolkitMarketplace = () => { ...@@ -77,23 +113,15 @@ const ToolkitMarketplace = () => {
// 更新 URL 的函数 // 更新 URL 的函数
const updateUrlParams = useCallback( const updateUrlParams = useCallback(
(newSearch: string, newTags: string[]) => { (newSearch: string, newTags: string[], detailQuery = detailQueryRef.current) => {
try { try {
// 使用更安全的 URL 参数构建方式 const newUrl = buildMarketplacePageUrl({
const params: Record<string, string> = {}; pathname: router.pathname,
if (newSearch) { search: newSearch,
params.search = newSearch; tags: newTags,
} pluginId: detailQuery?.pluginId,
if (newTags.length > 0) { version: detailQuery?.version
params.tags = newTags.join(','); });
}
// 手动构建查询字符串,避免 URLSearchParams 的安全问题
const queryString = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const newUrl = queryString ? `${router.pathname}?${queryString}` : router.pathname;
// 使用原生 History API 替代 Next.js router(更安全) // 使用原生 History API 替代 Next.js router(更安全)
if (typeof window !== 'undefined' && window.history && window.history.replaceState) { if (typeof window !== 'undefined' && window.history && window.history.replaceState) {
...@@ -119,15 +147,21 @@ const ToolkitMarketplace = () => { ...@@ -119,15 +147,21 @@ const ToolkitMarketplace = () => {
[router.pathname] [router.pathname]
); );
// 处理搜索框失焦,更新 URL const getCurrentDetailQuery = useCallback(() => {
const handleSearchBlur = useCallback(() => { if (typeof window === 'undefined') {
if (router.isReady) { return queryPluginId
updateUrlParams(searchText, selectedTagIds); ? {
pluginId: queryPluginId,
version: queryVersion
}
: {};
} }
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
// 监听 selectedTagIds 变化,更新 URL return getMarketplaceDetailQueryFromSearch(window.location.search);
useEffect(() => { }, [queryPluginId, queryVersion]);
// 处理搜索框失焦,更新 URL
const handleSearchBlur = useCallback(() => {
if (router.isReady) { if (router.isReady) {
updateUrlParams(searchText, selectedTagIds); updateUrlParams(searchText, selectedTagIds);
} }
...@@ -176,6 +210,56 @@ const ToolkitMarketplace = () => { ...@@ -176,6 +210,56 @@ const ToolkitMarketplace = () => {
}); });
}, [tools, i18n.language, toolTags]); }, [tools, i18n.language, toolTags]);
const selectedTagKey = selectedTagIds.join(',');
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: displayTools,
listKey: `${searchText}-${selectedTagKey}-${i18n.language}`,
estimatedRowHeight: 160,
estimatedRowGap: 20
});
useEffect(() => {
const currentDetailQuery = getCurrentDetailQuery();
if (!router.isReady || !currentDetailQuery.pluginId) return;
const queryDetail = {
pluginId: currentDetailQuery.pluginId,
version: currentDetailQuery.version
};
const matchedTool = displayTools.find((tool) => tool.id === currentDetailQuery.pluginId);
const queryTool = matchedTool
? {
...matchedTool,
version: currentDetailQuery.version || matchedTool.version
}
: createQuerySelectedTool(queryDetail);
detailQueryRef.current = {
pluginId: queryTool.id,
version: queryTool.version
};
setSelectedTool((prev) => {
if (
prev?.id === queryTool.id &&
prev.version === queryTool.version &&
prev.name === queryTool.name &&
prev.icon === queryTool.icon
) {
return prev;
}
return queryTool;
});
}, [displayTools, getCurrentDetailQuery, router.isReady]);
// 监听 selectedTagIds 变化,更新 URL
useEffect(() => {
if (router.isReady) {
updateUrlParams(searchText, selectedTagIds);
}
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
const onDownload = useCallback(async (toolId: string, version?: string) => { const onDownload = useCallback(async (toolId: string, version?: string) => {
try { try {
const url = await getDownloadURL(toolId, version); const url = await getDownloadURL(toolId, version);
...@@ -195,6 +279,53 @@ const ToolkitMarketplace = () => { ...@@ -195,6 +279,53 @@ const ToolkitMarketplace = () => {
} }
}, []); }, []);
const handleSelectTool = useCallback(
(tool: ToolCardItemType) => {
const detailQuery = getToolQuery(tool);
detailQueryRef.current = detailQuery;
setSelectedTool(tool);
updateUrlParams(searchText, selectedTagIds, detailQuery);
},
[searchText, selectedTagIds, updateUrlParams]
);
const handleCloseToolDetail = useCallback(() => {
detailQueryRef.current = null;
setSelectedTool(null);
updateUrlParams(searchText, selectedTagIds, null);
}, [searchText, selectedTagIds, updateUrlParams]);
const handleVersionChange = useCallback(
(nextVersion: string) => {
if (!selectedTool) return;
const detailQuery = {
pluginId: selectedTool.id,
version: nextVersion
};
detailQueryRef.current = detailQuery;
setSelectedTool((prev) => (prev ? { ...prev, version: nextVersion } : prev));
updateUrlParams(searchText, selectedTagIds, detailQuery);
},
[searchText, selectedTagIds, selectedTool, updateUrlParams]
);
const renderToolCard = useCallback(
(tool: ToolCardItemType) => (
<Box key={tool.id} data-virtual-item="">
<ToolCard
item={tool}
mode="marketplace"
onInstall={() => onDownload(tool.id)}
onClickCard={() => handleSelectTool(tool)}
/>
</Box>
),
[handleSelectTool, onDownload]
);
// 使用 IntersectionObserver 监听英雄区域是否在视窗中 // 使用 IntersectionObserver 监听英雄区域是否在视窗中
useEffect(() => { useEffect(() => {
const heroSection = heroSectionRef.current; const heroSection = heroSectionRef.current;
...@@ -448,21 +579,12 @@ const ToolkitMarketplace = () => { ...@@ -448,21 +579,12 @@ const ToolkitMarketplace = () => {
</Flex> </Flex>
{displayTools.length > 0 ? ( {displayTools.length > 0 ? (
<Grid <Grid
ref={gridRef}
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']} gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']}
gridGap={5} gridGap={5}
alignItems={'stretch'} alignItems={'stretch'}
> >
{displayTools.map((tool) => { {renderVirtualGridItems(renderToolCard)}
return (
<ToolCard
key={tool.id}
item={tool}
mode="marketplace"
onInstall={() => onDownload(tool.id)}
onClickCard={() => setSelectedTool(tool)}
/>
);
})}
</Grid> </Grid>
) : ( ) : (
<EmptyTip /> <EmptyTip />
...@@ -473,7 +595,7 @@ const ToolkitMarketplace = () => { ...@@ -473,7 +595,7 @@ const ToolkitMarketplace = () => {
{!!selectedTool && ( {!!selectedTool && (
<ToolDetailDrawer <ToolDetailDrawer
onClose={() => setSelectedTool(null)} onClose={handleCloseToolDetail}
showPoint={false} showPoint={false}
mode="marketplace" mode="marketplace"
selectedTool={selectedTool} selectedTool={selectedTool}
...@@ -485,6 +607,7 @@ const ToolkitMarketplace = () => { ...@@ -485,6 +607,7 @@ const ToolkitMarketplace = () => {
onDownload(selectedTool.id, version); onDownload(selectedTool.id, version);
}} }}
onFetchVersions={getMarketplaceToolVersions} onFetchVersions={getMarketplaceToolVersions}
onVersionChange={handleVersionChange}
/> />
)} )}
</> </>
......
export type MarketplaceDetailQuery = {
pluginId?: string;
version?: string;
};
export type MarketplacePageQuery = MarketplaceDetailQuery & {
search?: string;
tags?: string[];
};
export type NextQueryValue = string | string[] | undefined;
export const getSingleQueryValue = (value: NextQueryValue) => {
const queryValue = Array.isArray(value) ? value[0] : value;
const normalizedValue = queryValue?.trim();
return normalizedValue || undefined;
};
export const buildMarketplaceQueryString = ({
search,
tags,
pluginId,
version
}: MarketplacePageQuery) => {
const params: Array<[string, string]> = [];
if (search) {
params.push(['search', search]);
}
if (tags && tags.length > 0) {
params.push(['tags', tags.join(',')]);
}
if (pluginId) {
params.push(['pluginId', pluginId]);
}
if (pluginId && version) {
params.push(['version', version]);
}
return params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
};
export const buildMarketplacePageUrl = ({
pathname,
...query
}: MarketplacePageQuery & { pathname: string }) => {
const queryString = buildMarketplaceQueryString(query);
return queryString ? `${pathname}?${queryString}` : pathname;
};
export const getMarketplaceDetailQueryFromSearch = (search: string): MarketplaceDetailQuery => {
const searchParams = new URLSearchParams(search);
const pluginId = getSingleQueryValue(searchParams.get('pluginId') ?? undefined);
if (!pluginId) return {};
return {
pluginId,
version: getSingleQueryValue(searchParams.get('version') ?? undefined)
};
};
import { describe, expect, it } from 'vitest';
import {
buildMarketplacePageUrl,
buildMarketplaceQueryString,
getMarketplaceDetailQueryFromSearch,
getSingleQueryValue
} from '../../src/web/query';
describe('marketplace page query helpers', () => {
it('builds query string with search, tags, plugin id and version', () => {
expect(
buildMarketplaceQueryString({
search: 'weather tool',
tags: ['ai', 'search'],
pluginId: 'tool/set child',
version: '1.0.0 beta'
})
).toBe(
'search=weather%20tool&tags=ai%2Csearch&pluginId=tool%2Fset%20child&version=1.0.0%20beta'
);
});
it('omits version when pluginId is empty', () => {
expect(
buildMarketplaceQueryString({
version: '1.0.0'
})
).toBe('');
});
it('builds page URL without empty query params', () => {
expect(
buildMarketplacePageUrl({
pathname: '/',
search: '',
tags: [],
pluginId: 'tool-a'
})
).toBe('/?pluginId=tool-a');
});
it('normalizes next query values', () => {
expect(getSingleQueryValue([' tool-a ', 'tool-b'])).toBe('tool-a');
expect(getSingleQueryValue('')).toBeUndefined();
expect(getSingleQueryValue(undefined)).toBeUndefined();
});
it('reads detail query from browser search string', () => {
expect(
getMarketplaceDetailQueryFromSearch('?search=tool&pluginId=tool%2Fset&version=1.0.0%20beta')
).toEqual({
pluginId: 'tool/set',
version: '1.0.0 beta'
});
expect(getMarketplaceDetailQueryFromSearch('?version=1.0.0')).toEqual({});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment