mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
4357
web/pnpm-lock.yaml
generated
4357
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,13 @@
|
|||||||
import styles from './layout.module.css';
|
import styles from './layout.module.css';
|
||||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
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 { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||||
import { I18nObject } from '@/app/infra/entities/common';
|
import { I18nObject } from '@/app/infra/entities/common';
|
||||||
import { userInfo, initializeUserInfo } from '@/app/infra/http';
|
import { userInfo, initializeUserInfo } from '@/app/infra/http';
|
||||||
@@ -39,7 +45,9 @@ export default function HomeLayout({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.homeLayoutContainer}>
|
<div className={styles.homeLayoutContainer}>
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
|
<Suspense fallback={<div />}>
|
||||||
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
||||||
|
</Suspense>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,14 +10,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import {
|
import { Search, Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
|
||||||
Search,
|
|
||||||
Loader2,
|
|
||||||
Wrench,
|
|
||||||
AudioWaveform,
|
|
||||||
Hash,
|
|
||||||
Book,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
@@ -28,6 +20,8 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
|
import { TagsFilter } from './TagsFilter';
|
||||||
|
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
||||||
|
|
||||||
interface SortOption {
|
interface SortOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -43,10 +37,12 @@ function MarketPageContent({
|
|||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [componentFilter, setComponentFilter] = useState<string>('all');
|
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 [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
@@ -112,6 +108,7 @@ function MarketPageContent({
|
|||||||
githubURL: plugin.repository,
|
githubURL: plugin.repository,
|
||||||
version: plugin.latest_version,
|
version: plugin.latest_version,
|
||||||
components: plugin.components,
|
components: plugin.components,
|
||||||
|
tags: plugin.tags || [],
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ function MarketPageContent({
|
|||||||
const filterValue =
|
const filterValue =
|
||||||
componentFilter === 'all' ? undefined : componentFilter;
|
componentFilter === 'all' ? undefined : componentFilter;
|
||||||
|
|
||||||
// Always use searchMarketplacePlugins to support component filtering
|
// Always use searchMarketplacePlugins to support component filtering and tags filtering
|
||||||
const response =
|
const response =
|
||||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||||
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
|
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
|
||||||
@@ -138,6 +135,7 @@ function MarketPageContent({
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filterValue,
|
filterValue,
|
||||||
|
selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const data: ApiRespMarketplacePlugins = response;
|
const data: ApiRespMarketplacePlugins = response;
|
||||||
@@ -166,6 +164,7 @@ function MarketPageContent({
|
|||||||
[
|
[
|
||||||
searchQuery,
|
searchQuery,
|
||||||
componentFilter,
|
componentFilter,
|
||||||
|
selectedTags,
|
||||||
pageSize,
|
pageSize,
|
||||||
transformToVO,
|
transformToVO,
|
||||||
plugins.length,
|
plugins.length,
|
||||||
@@ -176,8 +175,34 @@ function MarketPageContent({
|
|||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlugins(1, false, true);
|
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(
|
const handleSearch = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
@@ -228,16 +253,19 @@ function MarketPageContent({
|
|||||||
fetchPlugins(1, !!searchQuery.trim(), true);
|
fetchPlugins(1, !!searchQuery.trim(), true);
|
||||||
}, [sortOption, componentFilter]);
|
}, [sortOption, componentFilter]);
|
||||||
|
|
||||||
// 处理URL参数,重定向到 LangBot Space
|
// Tags 筛选变化时重新搜索
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const author = searchParams.get('author');
|
if (!isLoading) {
|
||||||
const pluginName = searchParams.get('plugin');
|
setCurrentPage(1);
|
||||||
|
fetchPlugins(1, searchQuery.trim() !== '', true);
|
||||||
if (author && pluginName) {
|
|
||||||
const detailUrl = `https://space.langbot.app/market/${author}/${pluginName}`;
|
|
||||||
window.open(detailUrl, '_blank');
|
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedTags]);
|
||||||
|
|
||||||
|
// 处理 tags 变化
|
||||||
|
const handleTagsChange = useCallback((tags: string[]) => {
|
||||||
|
setSelectedTags(tags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 处理安装插件
|
// 处理安装插件
|
||||||
const handleInstallPlugin = useCallback(
|
const handleInstallPlugin = useCallback(
|
||||||
@@ -343,8 +371,8 @@ function MarketPageContent({
|
|||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Fixed header with search and sort controls */}
|
{/* 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">
|
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
|
||||||
{/* Search box */}
|
{/* Search box and Tags filter */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||||
<div className="relative w-full max-w-2xl">
|
<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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
@@ -363,6 +391,13 @@ function MarketPageContent({
|
|||||||
className="pl-10 pr-4 text-sm sm:text-base"
|
className="pl-10 pr-4 text-sm sm:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags filter */}
|
||||||
|
<TagsFilter
|
||||||
|
availableTags={availableTags}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
onTagsChange={handleTagsChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Component filter and sort */}
|
{/* Component filter and sort */}
|
||||||
@@ -477,6 +512,7 @@ function MarketPageContent({
|
|||||||
key={plugin.pluginId}
|
key={plugin.pluginId}
|
||||||
cardVO={plugin}
|
cardVO={plugin}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
|
tagNames={tagNames}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
117
web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
Normal file
117
web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import { Button } from '@/components/ui/button';
|
|||||||
export default function PluginMarketCardComponent({
|
export default function PluginMarketCardComponent({
|
||||||
cardVO,
|
cardVO,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
tagNames = {},
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginMarketCardVO;
|
cardVO: PluginMarketCardVO;
|
||||||
onInstall?: (author: string, pluginName: string) => void;
|
onInstall?: (author: string, pluginName: string) => void;
|
||||||
|
tagNames?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -42,13 +44,6 @@ export default function PluginMarketCardComponent({
|
|||||||
KnowledgeRetriever: <Book className="w-4 h-4" />,
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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>
|
</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="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-[0.3rem] sm:gap-[0.4rem]">
|
<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
|
<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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -112,11 +109,48 @@ export default function PluginMarketCardComponent({
|
|||||||
<polyline points="7,10 12,15 17,10" />
|
<polyline points="7,10 12,15 17,10" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
</svg>
|
</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()}
|
{cardVO.installCount.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
|
||||||
<div className="flex flex-row items-center gap-1">
|
<div className="flex flex-row items-center gap-1">
|
||||||
@@ -127,10 +161,6 @@ export default function PluginMarketCardComponent({
|
|||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{kindIconMap[kind]}
|
{kindIconMap[kind]}
|
||||||
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
|
||||||
<span className="hidden md:inline">
|
|
||||||
{componentKindNameMap[kind]}
|
|
||||||
</span>
|
|
||||||
<span className="ml-1">{count}</span>
|
<span className="ml-1">{count}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface IPluginMarketCardVO {
|
|||||||
githubURL: string;
|
githubURL: string;
|
||||||
version: string;
|
version: string;
|
||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||||
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
installCount: number;
|
installCount: number;
|
||||||
version: string;
|
version: string;
|
||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
constructor(prop: IPluginMarketCardVO) {
|
constructor(prop: IPluginMarketCardVO) {
|
||||||
this.description = prop.description;
|
this.description = prop.description;
|
||||||
@@ -34,5 +36,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
this.pluginId = prop.pluginId;
|
this.pluginId = prop.pluginId;
|
||||||
this.version = prop.version;
|
this.version = prop.version;
|
||||||
this.components = prop.components;
|
this.components = prop.components;
|
||||||
|
this.tags = prop.tags;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
sort_by?: string,
|
sort_by?: string,
|
||||||
sort_order?: string,
|
sort_order?: string,
|
||||||
component_filter?: string,
|
component_filter?: string,
|
||||||
|
tags_filter?: string[],
|
||||||
): Promise<ApiRespMarketplacePlugins> {
|
): Promise<ApiRespMarketplacePlugins> {
|
||||||
return this.post<ApiRespMarketplacePlugins>(
|
return this.post<ApiRespMarketplacePlugins>(
|
||||||
'/api/v1/marketplace/plugins/search',
|
'/api/v1/marketplace/plugins/search',
|
||||||
@@ -45,6 +46,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
sort_by,
|
sort_by,
|
||||||
sort_order,
|
sort_order,
|
||||||
component_filter,
|
component_filter,
|
||||||
|
tags_filter,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,20 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
public getLangBotReleases(): Promise<GitHubRelease[]> {
|
public getLangBotReleases(): Promise<GitHubRelease[]> {
|
||||||
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
|
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 {
|
export interface GitHubRelease {
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const enUS = {
|
|||||||
downloadFailed: 'Download failed',
|
downloadFailed: 'Download failed',
|
||||||
noReadme: 'This plugin does not provide README documentation',
|
noReadme: 'This plugin does not provide README documentation',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
tags: 'Tags',
|
tagLabel: 'Tags',
|
||||||
submissionTitle: 'You have a plugin submission under review: {{name}}',
|
submissionTitle: 'You have a plugin submission under review: {{name}}',
|
||||||
submissionPending: 'Your plugin submission is under review: {{name}}',
|
submissionPending: 'Your plugin submission is under review: {{name}}',
|
||||||
submissionApproved: 'Your plugin submission has been approved: {{name}}',
|
submissionApproved: 'Your plugin submission has been approved: {{name}}',
|
||||||
@@ -462,6 +462,13 @@ const enUS = {
|
|||||||
allComponents: 'All Components',
|
allComponents: 'All Components',
|
||||||
requestPlugin: 'Request Plugin',
|
requestPlugin: 'Request Plugin',
|
||||||
viewDetails: 'View Details',
|
viewDetails: 'View Details',
|
||||||
|
tags: {
|
||||||
|
filterByTags: 'Filter by Tags',
|
||||||
|
selected: 'selected',
|
||||||
|
selectTags: 'Select Tags',
|
||||||
|
clearAll: 'Clear All',
|
||||||
|
noTags: 'No tags available',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ const jaJP = {
|
|||||||
downloadFailed: 'ダウンロード失敗',
|
downloadFailed: 'ダウンロード失敗',
|
||||||
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
||||||
description: '説明',
|
description: '説明',
|
||||||
tags: 'タグ',
|
tagLabel: 'タグ',
|
||||||
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
|
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
|
||||||
submissionPending: 'プラグインの提出が審査中です: {{name}}',
|
submissionPending: 'プラグインの提出が審査中です: {{name}}',
|
||||||
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
|
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
|
||||||
@@ -462,6 +462,13 @@ const jaJP = {
|
|||||||
filterByComponent: 'コンポーネント',
|
filterByComponent: 'コンポーネント',
|
||||||
allComponents: '全部コンポーネント',
|
allComponents: '全部コンポーネント',
|
||||||
requestPlugin: 'プラグインをリクエスト',
|
requestPlugin: 'プラグインをリクエスト',
|
||||||
|
tags: {
|
||||||
|
filterByTags: 'タグで絞り込み',
|
||||||
|
selected: '選択済み',
|
||||||
|
selectTags: 'タグを選択',
|
||||||
|
clearAll: 'クリア',
|
||||||
|
noTags: 'タグがありません',
|
||||||
|
},
|
||||||
viewDetails: '詳細を表示',
|
viewDetails: '詳細を表示',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ const zhHans = {
|
|||||||
downloadFailed: '下载失败',
|
downloadFailed: '下载失败',
|
||||||
noReadme: '该插件没有提供 README 文档',
|
noReadme: '该插件没有提供 README 文档',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
tags: '标签',
|
tagLabel: '标签',
|
||||||
submissionTitle: '您有插件提交正在审核中: {{name}}',
|
submissionTitle: '您有插件提交正在审核中: {{name}}',
|
||||||
submissionApproved: '您的插件提交已通过审核: {{name}}',
|
submissionApproved: '您的插件提交已通过审核: {{name}}',
|
||||||
submissionRejected: '您的插件提交已被拒绝: {{name}}',
|
submissionRejected: '您的插件提交已被拒绝: {{name}}',
|
||||||
@@ -439,6 +439,13 @@ const zhHans = {
|
|||||||
filterByComponent: '组件',
|
filterByComponent: '组件',
|
||||||
allComponents: '全部组件',
|
allComponents: '全部组件',
|
||||||
requestPlugin: '请求插件',
|
requestPlugin: '请求插件',
|
||||||
|
tags: {
|
||||||
|
filterByTags: '按标签筛选',
|
||||||
|
selected: '已选',
|
||||||
|
selectTags: '选择标签',
|
||||||
|
clearAll: '清空',
|
||||||
|
noTags: '暂无标签',
|
||||||
|
},
|
||||||
viewDetails: '查看详情',
|
viewDetails: '查看详情',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ const zhHant = {
|
|||||||
downloadFailed: '下載失敗',
|
downloadFailed: '下載失敗',
|
||||||
noReadme: '該插件沒有提供 README 文件',
|
noReadme: '該插件沒有提供 README 文件',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
tags: '標籤',
|
tagLabel: '標籤',
|
||||||
submissionTitle: '您有插件提交正在審核中: {{name}}',
|
submissionTitle: '您有插件提交正在審核中: {{name}}',
|
||||||
submissionApproved: '您的插件提交已通過審核: {{name}}',
|
submissionApproved: '您的插件提交已通過審核: {{name}}',
|
||||||
submissionRejected: '您的插件提交已被拒絕: {{name}}',
|
submissionRejected: '您的插件提交已被拒絕: {{name}}',
|
||||||
@@ -432,6 +432,13 @@ const zhHant = {
|
|||||||
filterByComponent: '組件',
|
filterByComponent: '組件',
|
||||||
allComponents: '全部組件',
|
allComponents: '全部組件',
|
||||||
requestPlugin: '請求插件',
|
requestPlugin: '請求插件',
|
||||||
|
tags: {
|
||||||
|
filterByTags: '按標籤篩選',
|
||||||
|
selected: '已選',
|
||||||
|
selectTags: '選擇標籤',
|
||||||
|
clearAll: '清空',
|
||||||
|
noTags: '暫無標籤',
|
||||||
|
},
|
||||||
viewDetails: '查看詳情',
|
viewDetails: '查看詳情',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
Reference in New Issue
Block a user