feat: add tag filtering functionality to Plugin Market

- Introduced TagsFilter component for selecting and filtering plugins by tags.
- Updated PluginMarketComponent to handle tag selection and display.
- Enhanced PluginMarketCardComponent to show selected tags.
- Modified CloudServiceClient to fetch available tags from the API.
- Updated localization files to support new tag-related strings.
This commit is contained in:
Junyan Qin
2026-01-29 16:08:05 +08:00
parent b89a240250
commit aeac79e1b3
11 changed files with 3554 additions and 1151 deletions

4357
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,13 @@
import styles from './layout.module.css';
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import React, {
useState,
useCallback,
useMemo,
useEffect,
Suspense,
} from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { I18nObject } from '@/app/infra/entities/common';
import { userInfo, initializeUserInfo } from '@/app/infra/http';
@@ -39,7 +45,9 @@ export default function HomeLayout({
return (
<div className={styles.homeLayoutContainer}>
<aside className={styles.sidebar}>
<Suspense fallback={<div />}>
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
</Suspense>
</aside>
<div className={styles.main}>

View File

@@ -1,7 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -11,14 +10,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
Loader2,
Wrench,
AudioWaveform,
Hash,
Book,
} from 'lucide-react';
import { Search, Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -28,6 +20,8 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface SortOption {
value: string;
@@ -43,10 +37,12 @@ function MarketPageContent({
installPlugin: (plugin: PluginV4) => void;
}) {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>('all');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -112,6 +108,7 @@ function MarketPageContent({
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}, []);
@@ -129,7 +126,7 @@ function MarketPageContent({
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
// Always use searchMarketplacePlugins to support component filtering
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
@@ -138,6 +135,7 @@ function MarketPageContent({
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
);
const data: ApiRespMarketplacePlugins = response;
@@ -166,6 +164,7 @@ function MarketPageContent({
[
searchQuery,
componentFilter,
selectedTags,
pageSize,
transformToVO,
plugins.length,
@@ -176,8 +175,34 @@ function MarketPageContent({
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
fetchAvailableTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取可用标签
const fetchAvailableTags = async () => {
try {
const response = await getCloudServiceClientSync().getAllTags();
const tags = response.tags || [];
setAvailableTags(tags);
// Build tag names map for all components to use
const nameMap: Record<string, string> = {};
tags.forEach((tag: PluginTag) => {
const displayName = {
en_US: tag.display_name.en_US || tag.tag,
zh_Hans: tag.display_name.zh_Hans || tag.tag,
zh_Hant: tag.display_name.zh_Hant,
ja_JP: tag.display_name.ja_JP,
};
nameMap[tag.tag] = extractI18nObject(displayName);
});
setTagNames(nameMap);
} catch (error) {
console.error('Failed to fetch tags:', error);
}
};
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
@@ -228,16 +253,19 @@ function MarketPageContent({
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
// 处理URL参数重定向到 LangBot Space
// Tags 筛选变化时重新搜索
useEffect(() => {
const author = searchParams.get('author');
const pluginName = searchParams.get('plugin');
if (author && pluginName) {
const detailUrl = `https://space.langbot.app/market/${author}/${pluginName}`;
window.open(detailUrl, '_blank');
if (!isLoading) {
setCurrentPage(1);
fetchPlugins(1, searchQuery.trim() !== '', true);
}
}, [searchParams]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTags]);
// 处理 tags 变化
const handleTagsChange = useCallback((tags: string[]) => {
setSelectedTags(tags);
}, []);
// 处理安装插件
const handleInstallPlugin = useCallback(
@@ -343,8 +371,8 @@ function MarketPageContent({
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box */}
<div className="flex items-center justify-center">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
@@ -363,6 +391,13 @@ function MarketPageContent({
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
@@ -477,6 +512,7 @@ function MarketPageContent({
key={plugin.pluginId}
cardVO={plugin}
onInstall={handleInstallPlugin}
tagNames={tagNames}
/>
))}
</div>

View File

@@ -0,0 +1,117 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectGroup,
SelectTrigger,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Tag as TagIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface TagsFilterProps {
availableTags: PluginTag[];
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
}
export function TagsFilter({
availableTags,
selectedTags,
onTagsChange,
}: TagsFilterProps) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const handleTagToggle = (tag: string) => {
const newTags = selectedTags.includes(tag)
? selectedTags.filter((t) => t !== tag)
: [...selectedTags, tag];
onTagsChange(newTags);
};
const handleClearAll = () => {
onTagsChange([]);
};
const extractI18nObject = (obj: { zh_Hans?: string; en_US?: string }) => {
const lang = i18n.language || 'en_US';
return obj[lang as keyof typeof obj] || obj.zh_Hans || obj.en_US || '';
};
return (
<Select open={open} onOpenChange={setOpen}>
<SelectTrigger className="w-[140px]">
<div className="flex items-center gap-2 w-full">
<TagIcon className="h-4 w-4 flex-shrink-0" />
{selectedTags.length === 0 ? (
<span className="text-muted-foreground truncate text-sm">
{t('market.tags.filterByTags')}
</span>
) : (
<span className="text-sm truncate">
{selectedTags.length} {t('market.tags.selected')}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="w-[240px]">
<SelectGroup>
<div className="px-2 py-1.5 flex items-center justify-between border-b">
<span className="text-sm font-medium">
{t('market.tags.selectTags')}
</span>
{selectedTags.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-auto p-0 text-xs hover:bg-transparent hover:text-destructive"
>
{t('market.tags.clearAll')}
</Button>
)}
</div>
{availableTags.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
{t('market.tags.noTags')}
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{availableTags.map((tag) => (
<div
key={tag.tag}
className="flex items-center space-x-2 px-2 py-2 hover:bg-accent cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleTagToggle(tag.tag);
}}
>
<Checkbox
id={`tag-${tag.tag}`}
checked={selectedTags.includes(tag.tag)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => handleTagToggle(tag.tag)}
/>
<Label
htmlFor={`tag-${tag.tag}`}
className="text-sm font-normal cursor-pointer flex-1"
onClick={(e) => e.preventDefault()}
>
{extractI18nObject(tag.display_name)}
</Label>
</div>
))}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@@ -15,9 +15,11 @@ import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
cardVO,
onInstall,
tagNames = {},
}: {
cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void;
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -42,13 +44,6 @@ export default function PluginMarketCardComponent({
KnowledgeRetriever: <Book className="w-4 h-4" />,
};
const componentKindNameMap: Record<string, string> = {
Tool: t('plugins.componentName.Tool'),
EventListener: t('plugins.componentName.EventListener'),
Command: t('plugins.componentName.Command'),
KnowledgeRetriever: t('plugins.componentName.KnowledgeRetriever'),
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
@@ -97,11 +92,13 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
{/* 下部分:下载量、标签和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -112,11 +109,48 @@ export default function PluginMarketCardComponent({
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
</div>
{/* Tags */}
{cardVO.tags && cardVO.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{cardVO.tags.slice(0, 2).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
>
<svg
className="w-2.5 h-2.5 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<span className="truncate">{tagNames[tag] || tag}</span>
</Badge>
))}
{cardVO.tags.length > 2 && (
<Badge
variant="outline"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
>
+{cardVO.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
@@ -127,10 +161,6 @@ export default function PluginMarketCardComponent({
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
<span className="hidden md:inline">
{componentKindNameMap[kind]}
</span>
<span className="ml-1">{count}</span>
</Badge>
))}

View File

@@ -9,6 +9,7 @@ export interface IPluginMarketCardVO {
githubURL: string;
version: string;
components?: Record<string, number>;
tags?: string[];
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
installCount: number;
version: string;
components?: Record<string, number>;
tags?: string[];
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -34,5 +36,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
}
}

View File

@@ -35,6 +35,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by?: string,
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
): Promise<ApiRespMarketplacePlugins> {
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
@@ -45,6 +46,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
tags_filter,
},
);
}
@@ -92,6 +94,20 @@ export class CloudServiceClient extends BaseHttpClient {
public getLangBotReleases(): Promise<GitHubRelease[]> {
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
}
public getAllTags(): Promise<{ tags: PluginTag[] }> {
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
}
}
export interface PluginTag {
tag: string;
display_name: {
zh_Hans?: string;
en_US?: string;
zh_Hant?: string;
ja_JP?: string;
};
}
export interface GitHubRelease {

View File

@@ -446,7 +446,7 @@ const enUS = {
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
description: 'Description',
tags: 'Tags',
tagLabel: 'Tags',
submissionTitle: 'You have a plugin submission under review: {{name}}',
submissionPending: 'Your plugin submission is under review: {{name}}',
submissionApproved: 'Your plugin submission has been approved: {{name}}',
@@ -462,6 +462,13 @@ const enUS = {
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
selectTags: 'Select Tags',
clearAll: 'Clear All',
noTags: 'No tags available',
},
},
mcp: {
title: 'MCP',

View File

@@ -447,7 +447,7 @@ const jaJP = {
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
description: '説明',
tags: 'タグ',
tagLabel: 'タグ',
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
submissionPending: 'プラグインの提出が審査中です: {{name}}',
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
@@ -462,6 +462,13 @@ const jaJP = {
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
selected: '選択済み',
selectTags: 'タグを選択',
clearAll: 'クリア',
noTags: 'タグがありません',
},
viewDetails: '詳細を表示',
},
mcp: {

View File

@@ -425,7 +425,7 @@ const zhHans = {
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
description: '描述',
tags: '标签',
tagLabel: '标签',
submissionTitle: '您有插件提交正在审核中: {{name}}',
submissionApproved: '您的插件提交已通过审核: {{name}}',
submissionRejected: '您的插件提交已被拒绝: {{name}}',
@@ -439,6 +439,13 @@ const zhHans = {
filterByComponent: '组件',
allComponents: '全部组件',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
selected: '已选',
selectTags: '选择标签',
clearAll: '清空',
noTags: '暂无标签',
},
viewDetails: '查看详情',
},
mcp: {

View File

@@ -418,7 +418,7 @@ const zhHant = {
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
description: '描述',
tags: '標籤',
tagLabel: '標籤',
submissionTitle: '您有插件提交正在審核中: {{name}}',
submissionApproved: '您的插件提交已通過審核: {{name}}',
submissionRejected: '您的插件提交已被拒絕: {{name}}',
@@ -432,6 +432,13 @@ const zhHant = {
filterByComponent: '組件',
allComponents: '全部組件',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
selected: '已選',
selectTags: '選擇標籤',
clearAll: '清空',
noTags: '暫無標籤',
},
viewDetails: '查看詳情',
},
mcp: {