feat: add plugin recommendation lists to market page (#2001)

This commit is contained in:
Junyan Chin
2026-02-24 21:24:36 +08:00
committed by GitHub
parent d6c10763a8
commit 1eda076b93
3 changed files with 229 additions and 2 deletions

View File

@@ -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<NodeJS.Timeout | null>(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<string>();
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 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingSpinner text={t('market.loading')} />
@@ -507,7 +580,7 @@ function MarketPageContent({
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
{plugins.map((plugin) => (
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}

View File

@@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
export interface RecommendationList {
uuid: string;
label: I18nObject;
sort_order: number;
plugins: PluginV4[];
}
const PAGE_SIZE = 4; // plugins per page in a recommendation row
function pluginToVO(
plugin: PluginV4,
t: (key: string) => 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<string, string>;
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 (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-yellow-500" />
<h3 className="font-semibold text-base">
{extractI18nObject(list.label)}
</h3>
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="h-7 w-7 p-0"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground px-1">
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="h-7 w-7 p-0"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
/>
))}
</div>
{totalPages > 1 && <div className="border-b border-border mt-6" />}
</div>
);
}
export function RecommendationLists({
lists,
tagNames,
onInstall,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
}) {
if (!lists || lists.length === 0) return null;
return (
<div className="mt-6">
{lists.map((list) => (
<RecommendationListRow
key={list.uuid}
list={list}
tagNames={tagNames}
onInstall={onInstall}
/>
))}
<div className="border-b border-border mb-6" />
</div>
);
}

View File

@@ -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 {