From 996839800b2de65e51ec09a5239e1b6928df18c4 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Tue, 7 Jul 2026 12:27:45 +0800 Subject: [PATCH] feat(marketplace): add batch plugin selection --- .../core/plugin/tool/BatchOperationBar.tsx | 75 ++++++++ .../components/core/plugin/tool/ToolCard.tsx | 57 ++++++- packages/web/i18n/en/app.json | 2 + packages/web/i18n/zh-CN/app.json | 2 + packages/web/i18n/zh-Hant/app.json | 2 + .../app/src/pages/config/tool/marketplace.tsx | 160 +++++++++++++++++- .../src/web/core/plugin/marketplace/api.ts | 2 +- .../src/pages/api/tool/getDownloadUrl.ts | 19 +-- projects/marketplace/src/pages/index.tsx | 48 ++++-- .../pages/api/tool/getDownloadUrl.test.ts | 14 +- 10 files changed, 331 insertions(+), 50 deletions(-) create mode 100644 packages/web/components/core/plugin/tool/BatchOperationBar.tsx diff --git a/packages/web/components/core/plugin/tool/BatchOperationBar.tsx b/packages/web/components/core/plugin/tool/BatchOperationBar.tsx new file mode 100644 index 000000000000..b4057c60a2f2 --- /dev/null +++ b/packages/web/components/core/plugin/tool/BatchOperationBar.tsx @@ -0,0 +1,75 @@ +import { Box, Button, Checkbox, Flex } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import MyTooltip from '../../../common/MyTooltip'; + +type BatchOperationBarProps = { + selectedCount: number; + isAllSelected: boolean; + isIndeterminate?: boolean; + actionLabel: string; + onToggleSelectAll: () => void; + onAction: () => void | Promise; + isActionLoading?: boolean; + isActionDisabled?: boolean; +}; + +/** + * 插件市场卡片批量选择后的固定操作栏。 + * 只负责呈现选择计数、全选入口和当前页面注入的批量动作。 + */ +const BatchOperationBar = ({ + selectedCount, + isAllSelected, + isIndeterminate, + actionLabel, + onToggleSelectAll, + onAction, + isActionLoading, + isActionDisabled +}: BatchOperationBarProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + {t('common:select_count_num', { num: selectedCount })} + + + + + ); +}; + +export default BatchOperationBar; diff --git a/packages/web/components/core/plugin/tool/ToolCard.tsx b/packages/web/components/core/plugin/tool/ToolCard.tsx index 853dd18f73d6..cb2a1d017b19 100644 --- a/packages/web/components/core/plugin/tool/ToolCard.tsx +++ b/packages/web/components/core/plugin/tool/ToolCard.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, HStack } from '@chakra-ui/react'; +import { Box, Button, Checkbox, Flex, HStack } from '@chakra-ui/react'; import Avatar from '../../../common/Avatar'; import MyBox from '../../../common/MyBox'; import React, { useMemo, useRef, useState, useEffect } from 'react'; @@ -46,7 +46,10 @@ const ToolCard = ({ onUpdate, onClickCard, showActionButton = true, - variant = 'default' + variant = 'default', + showSelectCheckbox = false, + isSelected = false, + onToggleSelect }: { item: ToolCardItemType; systemTitle?: string; @@ -59,6 +62,9 @@ const ToolCard = ({ onClickCard?: () => void; showActionButton?: boolean; variant?: 'default' | 'marketplace'; + showSelectCheckbox?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; }) => { const { t, i18n } = useTranslation(); const tagsContainerRef = useRef(null); @@ -143,6 +149,8 @@ const ToolCard = ({ } }, [isMarketplaceVariant, item.installed, item.status, mode, t]); + const hasSelectCheckbox = !!onToggleSelect; + return ( + {hasSelectCheckbox && ( + { + e.stopPropagation(); + }} + > + { + e.stopPropagation(); + onToggleSelect?.(); + }} + /> + + )} + {/* Update badge in top-right corner */} {item.update && mode === 'admin' && !item.isDebug && ( { const { searchText, tagIds, sourceFilter, updateParams } = useSearchParams(); const [selectedTool, setSelectedTool] = useState(null); + const [selectedToolIds, setSelectedToolIds] = useState>(new Set()); + const clearSelectedToolIds = useCallback(() => { + setSelectedToolIds(new Set()); + }, []); const [installingOrDeletingToolIds, installingOrDeletingToolIdsDispatch] = useSet(); const [updatingToolIds, updatingToolIdsDispatch] = useSet(); const operatingPromisesRef = useRef>>(new Map()); @@ -190,9 +195,31 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { // Handle tag selection - update URL immediately const handleTagSelect = useCallback( (newTags: string[]) => { + clearSelectedToolIds(); updateParams({ newTags }); }, - [updateParams] + [clearSelectedToolIds, updateParams] + ); + const handleSourceSelect = useCallback( + (source?: MarketplaceSourceFilterValue) => { + clearSelectedToolIds(); + updateParams({ newSource: source }); + }, + [clearSelectedToolIds, updateParams] + ); + const handleInstalledFilterChange = useCallback( + (nextInstalledFilter: boolean) => { + clearSelectedToolIds(); + setInstalledFilter(nextInstalledFilter); + }, + [clearSelectedToolIds] + ); + const handleSearchChange = useCallback( + (value: string) => { + clearSelectedToolIds(); + setInputValue(value); + }, + [clearSelectedToolIds] ); // Control search box expansion based on focus and input value @@ -561,6 +588,106 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { return updatableList; }, [systemInstalledPlugins, marketplaceVersions, i18n.language]); + const selectedTools = useMemo( + () => displayTools.filter((tool) => selectedToolIds.has(tool.id)), + [displayTools, selectedToolIds] + ); + const installableSelectedTools = useMemo( + () => + selectedTools.filter((tool) => !tool.installed && !installingOrDeletingToolIds.has(tool.id)), + [installingOrDeletingToolIds, selectedTools] + ); + const updatableSelectedTools = useMemo( + () => + selectedTools.filter( + (tool) => tool.installed && tool.update && !updatingToolIds.has(tool.id) + ), + [selectedTools, updatingToolIds] + ); + const batchOperableSelectedTools = useMemo( + () => [...installableSelectedTools, ...updatableSelectedTools], + [installableSelectedTools, updatableSelectedTools] + ); + const batchOperationLabel = useMemo(() => { + const actionCount = batchOperableSelectedTools.length; + + if (installableSelectedTools.length > 0 && updatableSelectedTools.length > 0) { + return `${t('app:toolkit_batch_install_update')} (${actionCount})`; + } + + if (updatableSelectedTools.length > 0) { + return `${t('app:toolkit_batch_update')} (${actionCount})`; + } + + return `${t('app:toolkit_batch_install')} (${actionCount})`; + }, [ + batchOperableSelectedTools.length, + installableSelectedTools.length, + t, + updatableSelectedTools.length + ]); + const isAllSelected = displayTools.length > 0 && selectedTools.length === displayTools.length; + const isSelectIndeterminate = selectedTools.length > 0 && !isAllSelected; + + const toggleSelectTool = useCallback((toolId: string) => { + setSelectedToolIds((prev) => { + const next = new Set(prev); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + }, []); + + const toggleSelectAllTools = useCallback(() => { + setSelectedToolIds((prev) => { + const displayToolIds = displayTools.map((tool) => tool.id); + const selectedDisplayToolCount = displayToolIds.filter((id) => prev.has(id)).length; + const next = new Set(prev); + + if (selectedDisplayToolCount === displayToolIds.length) { + displayToolIds.forEach((id) => next.delete(id)); + return next; + } + + displayToolIds.forEach((id) => next.add(id)); + return next; + }); + }, [displayTools]); + + const { runAsync: handleBatchInstallOrUpdate, loading: isBatchInstallingOrUpdating } = useRequest( + async () => { + if (batchOperableSelectedTools.length === 0) return; + + const installToolIds = installableSelectedTools.map((tool) => tool.id); + const updateToolIds = updatableSelectedTools.map((tool) => tool.id); + const toolIds = [...installToolIds, ...updateToolIds]; + installToolIds.forEach((toolId) => installingOrDeletingToolIdsDispatch.add(toolId)); + updateToolIds.forEach((toolId) => updatingToolIdsDispatch.add(toolId)); + + try { + const downloadUrls = (await getMarketplaceDownloadURLs(toolIds)).filter(Boolean); + if (downloadUrls.length === 0) return; + + await intallPluginWithUrl({ downloadUrls }); + setSelectedToolIds(new Set()); + setSelectedTool((prev) => + prev && toolIds.includes(prev.id) ? { ...prev, installed: true, update: false } : prev + ); + await refreshInstalledPlugins(); + } finally { + installToolIds.forEach((toolId) => installingOrDeletingToolIdsDispatch.remove(toolId)); + updateToolIds.forEach((toolId) => updatingToolIdsDispatch.remove(toolId)); + } + }, + { + manual: true, + successToast: t('common:Success') + } + ); + if (toolsError && !loadingTools) { return ( @@ -699,7 +826,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { borderRadius={'md'} placeholder={t('app:toolkit_marketplace_search_placeholder')} value={inputValue} - onChange={(e) => setInputValue(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} onFocus={handleSearchFocus} onBlur={handleSearchBlur} /> @@ -715,7 +842,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { color={'myGray.500'} cursor={'pointer'} onClick={() => { - setInputValue(''); + handleSearchChange(''); }} /> )} @@ -746,7 +873,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { selectedTagIds={tagIds} onTagSelect={handleTagSelect} selectedSource={sourceFilter} - onSourceSelect={(source) => updateParams({ newSource: source })} + onSourceSelect={handleSourceSelect} variant="marketplace" /> @@ -755,7 +882,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { - + 0 ? 20 : 3}> { borderRadius={'10px'} placeholder={t('app:toolkit_marketplace_search_placeholder')} value={inputValue} - onChange={(e) => setInputValue(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} onFocus={handleSearchFocus} onBlur={handleSearchBlur} /> @@ -835,7 +962,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { selectedTagIds={tagIds} onTagSelect={handleTagSelect} selectedSource={sourceFilter} - onSourceSelect={(source) => updateParams({ newSource: source })} + onSourceSelect={handleSourceSelect} variant="marketplace" /> @@ -854,12 +981,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { children: [ { label: t('common:All'), - onClick: () => setInstalledFilter(false), + onClick: () => handleInstalledFilterChange(false), isActive: !installedFilter }, { label: t('app:toolkit_uninstalled'), - onClick: () => setInstalledFilter(true), + onClick: () => handleInstalledFilterChange(true), isActive: installedFilter } ] @@ -890,6 +1017,9 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { onUpdate={() => handleUpdateTool(tool)} onClickCard={() => setSelectedTool(tool)} showActionButton={!tool.installed} + showSelectCheckbox={selectedTools.length > 0} + isSelected={selectedToolIds.has(tool.id)} + onToggleSelect={() => toggleSelectTool(tool.id)} /> ); })} @@ -899,6 +1029,18 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { )} + {selectedTools.length > 0 && ( + + )} {!!selectedTool && ( diff --git a/projects/app/src/web/core/plugin/marketplace/api.ts b/projects/app/src/web/core/plugin/marketplace/api.ts index ce61bc8fecc4..8277507db8a0 100644 --- a/projects/app/src/web/core/plugin/marketplace/api.ts +++ b/projects/app/src/web/core/plugin/marketplace/api.ts @@ -25,7 +25,7 @@ export const getMarketplaceDownloadURL = (toolId: string, version?: string) => }); export const getMarketplaceDownloadURLs = (toolIds: string[]) => - POST('/marketplace/api/tool/getDownloadUrl', { toolIds }); + Promise.all(toolIds.map((toolId) => getMarketplaceDownloadURL(toolId))); export const getMarketplaceToolVersions = (toolId?: string) => GET('/marketplace/api/tool/versions', { diff --git a/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts b/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts index a9fc57b21e4c..d3ba228ccc3d 100644 --- a/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts +++ b/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts @@ -8,27 +8,22 @@ export type GetDownloadURLQuery = { toolId?: string; version?: string; }; -export type GetDownloadURLBody = { - toolIds?: string[]; -}; -export type GetDownloadURLResponse = string | string[]; +export type GetDownloadURLResponse = string; async function handler( - req: ApiRequestProps, + req: ApiRequestProps, GetDownloadURLQuery>, res: ApiResponseType ): Promise { const { toolId, version } = req.query; - const { toolIds } = req.body; - if (!toolId && !toolIds) { - throw new Error('toolId or toolIds is required'); + if (!toolId) { + throw new Error('toolId is required'); } - const filterTools = toolIds && toolIds.length > 0 ? toolIds : toolId ? [toolId] : []; const toolList = toolId && version ? await getToolList({ toolId: toolId.split('/')[0], version }) : await getToolList(); - const tools = toolList.filter((item) => filterTools.includes(item.toolId)); + const tools = toolList.filter((item) => item.toolId === toolId); if (toolId && version && tools.length === 0) { throw new Error('tool not found'); @@ -38,8 +33,6 @@ async function handler( await increaseDownloadCount(tool.toolId, 'tool'); } - return toolId - ? tools[0]?.downloadUrl || getPkgdownloadURL(toolId) - : Array.from(tools.map((tool) => tool.downloadUrl || getPkgdownloadURL(tool.toolId))); + return tools[0]?.downloadUrl || getPkgdownloadURL(toolId); } export default NextAPI(handler); diff --git a/projects/marketplace/src/pages/index.tsx b/projects/marketplace/src/pages/index.tsx index 597e1656d575..2bee7841af00 100644 --- a/projects/marketplace/src/pages/index.tsx +++ b/projects/marketplace/src/pages/index.tsx @@ -68,6 +68,23 @@ const isSameBrowserUrl = (nextUrl: string) => { const TOOL_GRID_TEMPLATE_COLUMNS = 'repeat(auto-fill, minmax(min(max(260px, calc((100% - 6.25rem) / 6)), 100%), 1fr))'; +/** + * 触发一组独立的浏览器下载任务,保持每个 marketplace 插件仍下载为单独的 .pkg 文件。 + */ +const startPkgDownloadTasks = (urls: string[]) => { + urls.forEach((url, index) => { + window.setTimeout(() => { + const link = document.createElement('a'); + link.href = url; + link.download = ''; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, index * 120); + }); +}; + const ToolkitMarketplace = () => { const { t, i18n } = useTranslation(); const router = useRouter(); @@ -206,6 +223,18 @@ const ToolkitMarketplace = () => { }, [searchText, selectedTagIds, updateUrlParams] ); + const handleTagSelect = useCallback( + (newTags: string[]) => { + setSelectedTagIds(newTags); + }, + [] + ); + const handleSearchChange = useCallback( + (value: string) => { + setInputValue(value); + }, + [] + ); const { data: tools, @@ -307,14 +336,7 @@ const ToolkitMarketplace = () => { try { const url = await getDownloadURL(toolId, version); if (url) { - // Create download link - const link = document.createElement('a'); - link.href = url; - link.download = ''; - link.style.display = 'none'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + startPkgDownloadTasks([url]); } } catch (error) { console.error('Download failed:', error); @@ -473,7 +495,7 @@ const ToolkitMarketplace = () => { placeholder={t('app:toolkit_marketplace_search_placeholder')} value={inputValue} onChange={(e) => { - setInputValue(e.target.value); + handleSearchChange(e.target.value); }} autoFocus onBlur={() => { @@ -495,7 +517,7 @@ const ToolkitMarketplace = () => { color={'myGray.500'} cursor={'pointer'} onClick={() => { - setInputValue(''); + handleSearchChange(''); setSearchText(''); setIsSearchExpanded(false); }} @@ -530,7 +552,7 @@ const ToolkitMarketplace = () => { { placeholder={t('app:toolkit_marketplace_search_placeholder')} value={inputValue} onChange={(e) => { - setInputValue(e.target.value); + handleSearchChange(e.target.value); }} onBlur={handleSearchBlur} /> @@ -618,7 +640,7 @@ const ToolkitMarketplace = () => { { }); }); - it('returns a URL list and counts downloads for batch requests', async () => { - dataMocks.getToolList.mockResolvedValue([ - { toolId: 'tool-a', downloadUrl: 'https://cdn.example.com/tool-a.pkg' }, - { toolId: 'tool-b' }, - { toolId: 'tool-c', downloadUrl: 'https://cdn.example.com/tool-c.pkg' } - ]); - + it('rejects batch download requests without a toolId', async () => { const { default: handler } = await import('../../../../src/pages/api/tool/getDownloadUrl'); const res = createResponse(); await handler( @@ -173,10 +167,10 @@ describe('/api/tool/getDownloadUrl', () => { res as any ); - expect(downloadMocks.increaseDownloadCount).toHaveBeenCalledTimes(2); + expect(dataMocks.getToolList).not.toHaveBeenCalled(); expect(res.body).toEqual({ - code: 200, - data: ['https://cdn.example.com/tool-a.pkg', 'https://fallback.example.com/tool-b.pkg'] + code: 500, + message: 'toolId is required' }); }); });