diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index 881633fd..2835796a 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { + useState, + useEffect, + useCallback, + useRef, + Suspense, + useMemo, +} from 'react'; import { Input } from '@/components/ui/input'; import { Select, @@ -23,6 +30,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { TagsFilter } from './TagsFilter'; import { PluginTag } from '@/app/infra/http/CloudServiceClient'; +import { RecommendationLists, RecommendationList } from './RecommendationLists'; + interface SortOption { value: string; label: string; @@ -50,6 +59,9 @@ function MarketPageContent({ const [currentPage, setCurrentPage] = useState(1); const [total, setTotal] = useState(0); const [sortOption, setSortOption] = useState('install_count_desc'); + const [recommendationLists, setRecommendationLists] = useState< + RecommendationList[] + >([]); const pageSize = 16; // 每页16个,4行x4列 const searchTimeoutRef = useRef(null); @@ -203,6 +215,20 @@ function MarketPageContent({ } }; + // Fetch recommendation lists + useEffect(() => { + async function fetchRecommendationLists() { + try { + const response = + await getCloudServiceClientSync().getRecommendationLists(); + setRecommendationLists(response.lists || []); + } catch (error) { + console.error('Failed to fetch recommendation lists:', error); + } + } + fetchRecommendationLists(); + }, []); + // 搜索功能 const handleSearch = useCallback( (query: string) => { @@ -306,6 +332,39 @@ function MarketPageContent({ }; }, []); + // 计算所有推荐插件的 ID 集合 + const recommendedPluginIds = useMemo(() => { + const ids = new Set(); + recommendationLists.forEach((list) => { + list.plugins.forEach((plugin) => { + ids.add(`${plugin.author} / ${plugin.name}`); + }); + }); + return ids; + }, [recommendationLists]); + + // 过滤掉已在推荐列表中展示的插件 + // 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤 + // 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现 + // 但是推荐列表只在第一页且无筛选时显示。 + // 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。 + const visiblePlugins = useMemo(() => { + const showRecommendations = + !searchQuery && componentFilter === 'all' && selectedTags.length === 0; + + if (!showRecommendations) { + return plugins; + } + + return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId)); + }, [ + plugins, + recommendedPluginIds, + searchQuery, + componentFilter, + selectedTags, + ]); + // 加载更多 const loadMore = useCallback(() => { if (!isLoadingMore && hasMore) { @@ -494,6 +553,20 @@ function MarketPageContent({ ref={scrollContainerRef} className="flex-1 overflow-y-auto px-3 sm:px-4" > + {/* Recommendation Lists */} + {!searchQuery && + componentFilter === 'all' && + selectedTags.length === 0 && + currentPage === 1 && ( +
+ +
+ )} + {isLoading ? (
@@ -507,7 +580,7 @@ function MarketPageContent({ ) : ( <>
- {plugins.map((plugin) => ( + {visiblePlugins.map((plugin) => ( string, +): PluginMarketCardVO { + return new PluginMarketCardVO({ + pluginId: plugin.author + ' / ' + plugin.name, + author: plugin.author, + pluginName: plugin.name, + label: extractI18nObject(plugin.label), + description: + extractI18nObject(plugin.description) || t('market.noDescription'), + installCount: plugin.install_count, + iconURL: getCloudServiceClientSync().getPluginIconURL( + plugin.author, + plugin.name, + ), + githubURL: plugin.repository, + version: plugin.latest_version, + components: plugin.components, + tags: plugin.tags || [], + }); +} + +function RecommendationListRow({ + list, + tagNames, + onInstall, +}: { + list: RecommendationList; + tagNames: Record; + onInstall: (author: string, pluginName: string) => void; +}) { + const { t } = useTranslation(); + const [page, setPage] = useState(0); + + const plugins = list.plugins || []; + const totalPages = Math.ceil(plugins.length / PAGE_SIZE); + const start = page * PAGE_SIZE; + const visiblePlugins = plugins.slice(start, start + PAGE_SIZE); + + if (plugins.length === 0) return null; + + return ( +
+
+
+ +

+ {extractI18nObject(list.label)} +

+
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+
+ {visiblePlugins.map((plugin) => ( + + ))} +
+ {totalPages > 1 &&
} +
+ ); +} + +export function RecommendationLists({ + lists, + tagNames, + onInstall, +}: { + lists: RecommendationList[]; + tagNames: Record; + onInstall: (author: string, pluginName: string) => void; +}) { + if (!lists || lists.length === 0) return null; + + return ( +
+ {lists.map((list) => ( + + ))} +
+
+ ); +} diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index cefd97af..1305f631 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -3,6 +3,8 @@ import { ApiRespMarketplacePluginDetail, ApiRespMarketplacePlugins, } from '@/app/infra/entities/api'; +import { PluginV4 } from '@/app/infra/entities/plugin'; +import { I18nObject } from '@/app/infra/entities/common'; /** * 云服务客户端 @@ -98,6 +100,19 @@ export class CloudServiceClient extends BaseHttpClient { public getAllTags(): Promise<{ tags: PluginTag[] }> { return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags'); } + + public getRecommendationLists(): Promise<{ lists: RecommendationList[] }> { + return this.get<{ lists: RecommendationList[] }>( + '/api/v1/marketplace/recommendation-lists', + ); + } +} + +export interface RecommendationList { + uuid: string; + label: I18nObject; + sort_order: number; + plugins: PluginV4[]; } export interface PluginTag {