Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
赵月辉
/
fastgpt-migrated
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
bd6eab3f
authored
Jun 10, 2026
by
Finley Ge
Committed by
GitHub
Jun 10, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
perf(marketplace): virtualize plugin grid rendering (#7089)
parent
53dd682a
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
287 additions
and
37 deletions
+287
-37
packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx
+6
-1
projects/marketplace/src/pages/index.tsx
+159
-36
projects/marketplace/src/web/query.ts
+65
-0
projects/marketplace/test/web/query.test.ts
+57
-0
No files found.
packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx
View file @
bd6eab3f
...
...
@@ -40,6 +40,7 @@ const ToolDetailDrawer = ({
systemTitle
,
onFetchDetail
,
onFetchVersions
,
onVersionChange
,
isLoading
,
showPoint
,
mode
,
...
...
@@ -53,6 +54,7 @@ const ToolDetailDrawer = ({
systemTitle
?:
string
;
onFetchDetail
?:
(
toolId
:
string
,
version
?:
string
)
=>
Promise
<
ToolDetailFetchResponse
>
;
onFetchVersions
?:
(
toolId
:
string
)
=>
Promise
<
ToolDetailVersionType
[]
>
;
onVersionChange
?:
(
version
:
string
)
=>
void
;
isLoading
?:
boolean
;
showPoint
:
boolean
;
mode
:
'admin'
|
'team'
|
'marketplace'
;
...
...
@@ -137,7 +139,10 @@ const ToolDetailDrawer = ({
label
:
item
.
version
,
description
:
item
.
versionDescription
,
isActive
:
item
.
version
===
currentVersion
,
onClick
:
()
=>
setSelectedVersion
(
item
.
version
)
onClick
:
()
=>
{
setSelectedVersion
(
item
.
version
);
onVersionChange
?.(
item
.
version
);
}
}))
}
]
}
...
...
projects/marketplace/src/pages/index.tsx
View file @
bd6eab3f
...
...
@@ -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
EmptyTip
from
'@fastgpt/web/components/common/EmptyTip'
;
import
{
usePagination
}
from
'@fastgpt/web/hooks/usePagination'
;
import
{
useVirtualGridList
}
from
'@fastgpt/web/hooks/useVirtualGridList'
;
import
{
parseI18nString
}
from
'@fastgpt/global/common/i18n/utils'
;
import
{
getDownloadURL
,
...
...
@@ -21,11 +22,38 @@ import {
import
{
useRequest
}
from
'@fastgpt/web/hooks/useRequest'
;
import
I18nLngSelector
from
'@/web/common/Select/I18nLngSelector'
;
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
{
t
,
i18n
}
=
useTranslation
();
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
[
searchText
,
setSearchText
]
=
useState
(
''
);
const
[
selectedTagIds
,
setSelectedTagIds
]
=
useState
<
string
[]
>
([]);
...
...
@@ -33,6 +61,14 @@ const ToolkitMarketplace = () => {
const
[
isSearchExpanded
,
setIsSearchExpanded
]
=
useState
(
false
);
const
[
showCompactSearch
,
setShowCompactSearch
]
=
useState
(
false
);
const
heroSectionRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
detailQueryRef
=
useRef
<
MarketplaceDetailQuery
|
null
>
(
queryPluginId
?
{
pluginId
:
queryPluginId
,
version
:
queryVersion
}
:
null
);
// 从 URL 初始化状态
useEffect
(()
=>
{
...
...
@@ -77,23 +113,15 @@ const ToolkitMarketplace = () => {
// 更新 URL 的函数
const
updateUrlParams
=
useCallback
(
(
newSearch
:
string
,
newTags
:
string
[])
=>
{
(
newSearch
:
string
,
newTags
:
string
[]
,
detailQuery
=
detailQueryRef
.
current
)
=>
{
try
{
// 使用更安全的 URL 参数构建方式
const
params
:
Record
<
string
,
string
>
=
{};
if
(
newSearch
)
{
params
.
search
=
newSearch
;
}
if
(
newTags
.
length
>
0
)
{
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
;
const
newUrl
=
buildMarketplacePageUrl
({
pathname
:
router
.
pathname
,
search
:
newSearch
,
tags
:
newTags
,
pluginId
:
detailQuery
?.
pluginId
,
version
:
detailQuery
?.
version
});
// 使用原生 History API 替代 Next.js router(更安全)
if
(
typeof
window
!==
'undefined'
&&
window
.
history
&&
window
.
history
.
replaceState
)
{
...
...
@@ -119,15 +147,21 @@ const ToolkitMarketplace = () => {
[
router
.
pathname
]
);
// 处理搜索框失焦,更新 URL
const
handleSearchBlur
=
useCallback
(()
=>
{
if
(
router
.
isReady
)
{
updateUrlParams
(
searchText
,
selectedTagIds
);
const
getCurrentDetailQuery
=
useCallback
(()
=>
{
if
(
typeof
window
===
'undefined'
)
{
return
queryPluginId
?
{
pluginId
:
queryPluginId
,
version
:
queryVersion
}
:
{};
}
},
[
router
.
isReady
,
searchText
,
selectedTagIds
,
updateUrlParams
]);
// 监听 selectedTagIds 变化,更新 URL
useEffect
(()
=>
{
return
getMarketplaceDetailQueryFromSearch
(
window
.
location
.
search
);
},
[
queryPluginId
,
queryVersion
]);
// 处理搜索框失焦,更新 URL
const
handleSearchBlur
=
useCallback
(()
=>
{
if
(
router
.
isReady
)
{
updateUrlParams
(
searchText
,
selectedTagIds
);
}
...
...
@@ -176,6 +210,56 @@ const ToolkitMarketplace = () => {
});
},
[
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
)
=>
{
try
{
const
url
=
await
getDownloadURL
(
toolId
,
version
);
...
...
@@ -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 监听英雄区域是否在视窗中
useEffect
(()
=>
{
const
heroSection
=
heroSectionRef
.
current
;
...
...
@@ -448,21 +579,12 @@ const ToolkitMarketplace = () => {
</
Flex
>
{
displayTools
.
length
>
0
?
(
<
Grid
ref=
{
gridRef
}
gridTemplateColumns=
{
[
'1fr'
,
'repeat(2,1fr)'
,
'repeat(3,1fr)'
,
'repeat(4,1fr)'
]
}
gridGap=
{
5
}
alignItems=
{
'stretch'
}
>
{
displayTools
.
map
((
tool
)
=>
{
return
(
<
ToolCard
key=
{
tool
.
id
}
item=
{
tool
}
mode=
"marketplace"
onInstall=
{
()
=>
onDownload
(
tool
.
id
)
}
onClickCard=
{
()
=>
setSelectedTool
(
tool
)
}
/>
);
})
}
{
renderVirtualGridItems
(
renderToolCard
)
}
</
Grid
>
)
:
(
<
EmptyTip
/>
...
...
@@ -473,7 +595,7 @@ const ToolkitMarketplace = () => {
{
!!
selectedTool
&&
(
<
ToolDetailDrawer
onClose=
{
()
=>
setSelectedTool
(
null
)
}
onClose=
{
handleCloseToolDetail
}
showPoint=
{
false
}
mode=
"marketplace"
selectedTool=
{
selectedTool
}
...
...
@@ -485,6 +607,7 @@ const ToolkitMarketplace = () => {
onDownload
(
selectedTool
.
id
,
version
);
}
}
onFetchVersions=
{
getMarketplaceToolVersions
}
onVersionChange=
{
handleVersionChange
}
/>
)
}
</>
...
...
projects/marketplace/src/web/query.ts
0 → 100644
View file @
bd6eab3f
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
)
};
};
projects/marketplace/test/web/query.test.ts
0 → 100644
View file @
bd6eab3f
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
({});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment