mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 20:44:36 +00:00
fix: align add extension marketplace ui
This commit is contained in:
@@ -19,7 +19,9 @@ import {
|
||||
Search,
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -35,8 +37,6 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
||||
|
||||
import { RecommendationLists, RecommendationList } from './RecommendationLists';
|
||||
|
||||
interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -65,6 +65,7 @@ function MarketPageContent({
|
||||
];
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [componentFilter, setComponentFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState<string>(() => {
|
||||
const type = searchParams.get('type');
|
||||
if (type && validTypes.includes(type)) {
|
||||
@@ -72,7 +73,8 @@ function MarketPageContent({
|
||||
}
|
||||
return 'all';
|
||||
});
|
||||
const activeAdvancedFilters = 0;
|
||||
const activeAdvancedFilters =
|
||||
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
|
||||
const [tagNames, setTagNames] = useState<Record<string, string>>({});
|
||||
@@ -83,9 +85,6 @@ 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 = 12; // 每页12个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -120,6 +119,27 @@ function MarketPageContent({
|
||||
},
|
||||
];
|
||||
|
||||
const componentOptions = [
|
||||
{ value: 'all', label: t('market.allComponents'), icon: null },
|
||||
{ value: 'Tool', label: t('market.componentName.Tool'), icon: Wrench },
|
||||
{ value: 'Command', label: t('market.componentName.Command'), icon: Hash },
|
||||
{
|
||||
value: 'EventListener',
|
||||
label: t('market.componentName.EventListener'),
|
||||
icon: AudioWaveform,
|
||||
},
|
||||
{
|
||||
value: 'KnowledgeEngine',
|
||||
label: t('market.componentName.KnowledgeEngine'),
|
||||
icon: Book,
|
||||
},
|
||||
{
|
||||
value: 'Parser',
|
||||
label: t('market.componentName.Parser'),
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取当前排序参数
|
||||
const getCurrentSort = useCallback(() => {
|
||||
const option = sortOptions.find((opt) => opt.value === sortOption);
|
||||
@@ -129,71 +149,30 @@ function MarketPageContent({
|
||||
}, [sortOption]);
|
||||
|
||||
// 将API响应转换为VO对象
|
||||
const transformToVO = useCallback((plugin: PluginV4): 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 || [],
|
||||
type: plugin.type,
|
||||
});
|
||||
}, []);
|
||||
const transformToVO = useCallback(
|
||||
(plugin: PluginV4): PluginMarketCardVO => {
|
||||
const cloudClient = getCloudServiceClientSync();
|
||||
const iconURL =
|
||||
plugin.type === 'mcp'
|
||||
? cloudClient.getMCPMarketplaceIconURL(plugin.author, plugin.name)
|
||||
: plugin.type === 'skill'
|
||||
? cloudClient.getSkillMarketplaceIconURL(plugin.author, plugin.name)
|
||||
: cloudClient.getPluginIconURL(plugin.author, plugin.name);
|
||||
|
||||
const transformMCPToVO = useCallback(
|
||||
(mcp: any): PluginMarketCardVO => {
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: mcp.author + ' / ' + mcp.name,
|
||||
author: mcp.author,
|
||||
pluginName: mcp.name,
|
||||
label: extractI18nObject(mcp.label),
|
||||
pluginId: plugin.author + ' / ' + plugin.name,
|
||||
author: plugin.author,
|
||||
pluginName: plugin.name,
|
||||
label: extractI18nObject(plugin.label),
|
||||
description:
|
||||
extractI18nObject(mcp.description) || t('market.noDescription'),
|
||||
installCount: mcp.install_count || 0,
|
||||
iconURL:
|
||||
mcp.icon ||
|
||||
getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
|
||||
githubURL: mcp.repository,
|
||||
version: mcp.latest_version,
|
||||
components: mcp.components || {},
|
||||
tags: mcp.tags || [],
|
||||
type: 'mcp',
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const transformSkillToVO = useCallback(
|
||||
(skill: any): PluginMarketCardVO => {
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: skill.author + ' / ' + skill.name,
|
||||
author: skill.author,
|
||||
pluginName: skill.name,
|
||||
label: extractI18nObject(skill.label),
|
||||
description:
|
||||
extractI18nObject(skill.description) || t('market.noDescription'),
|
||||
installCount: skill.install_count || 0,
|
||||
iconURL:
|
||||
skill.icon ||
|
||||
getCloudServiceClientSync().getPluginIconURL(
|
||||
skill.author,
|
||||
skill.name,
|
||||
),
|
||||
githubURL: skill.repository,
|
||||
version: skill.latest_version,
|
||||
components: skill.components || {},
|
||||
tags: skill.tags || [],
|
||||
type: 'skill',
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
components: plugin.components || {},
|
||||
tags: plugin.tags || [],
|
||||
type: plugin.type,
|
||||
});
|
||||
},
|
||||
[t],
|
||||
@@ -201,7 +180,12 @@ function MarketPageContent({
|
||||
|
||||
// 获取插件列表
|
||||
const fetchPlugins = useCallback(
|
||||
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
|
||||
async (
|
||||
page: number,
|
||||
isSearch: boolean = false,
|
||||
reset: boolean = false,
|
||||
queryOverride?: string,
|
||||
) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
@@ -210,109 +194,24 @@ function MarketPageContent({
|
||||
|
||||
try {
|
||||
const { sortBy, sortOrder } = getCurrentSort();
|
||||
const query = isSearch && searchQuery.trim() ? searchQuery.trim() : '';
|
||||
const query = (queryOverride ?? searchQuery).trim();
|
||||
|
||||
let newPlugins: PluginMarketCardVO[] = [];
|
||||
let total = 0;
|
||||
const response =
|
||||
await getCloudServiceClientSync().searchMarketplaceExtensions({
|
||||
query: isSearch ? query : '',
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
type_filter: typeFilter === 'all' ? undefined : typeFilter,
|
||||
component_filter:
|
||||
componentFilter === 'all' ? undefined : componentFilter,
|
||||
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
|
||||
if (typeFilter === 'all') {
|
||||
let pluginsResult: PluginMarketCardVO[] = [];
|
||||
let mcpsResult: PluginMarketCardVO[] = [];
|
||||
let skillsResult: PluginMarketCardVO[] = [];
|
||||
let pluginsTotal = 0;
|
||||
let mcpsTotal = 0;
|
||||
let skillsTotal = 0;
|
||||
|
||||
try {
|
||||
const pluginsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
'plugin',
|
||||
);
|
||||
pluginsResult = pluginsResponse.plugins
|
||||
.filter((plugin) => {
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(
|
||||
keys.length > 0 &&
|
||||
keys.every((k) => k === 'KnowledgeRetriever')
|
||||
);
|
||||
})
|
||||
.map(transformToVO);
|
||||
pluginsTotal = pluginsResponse.total || 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch plugins:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
const mcpsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
'mcp',
|
||||
);
|
||||
mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO);
|
||||
mcpsTotal = mcpsResponse.total || 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch mcps:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
const skillsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
'skill',
|
||||
);
|
||||
skillsResult = (skillsResponse.plugins || []).map(
|
||||
transformSkillToVO,
|
||||
);
|
||||
skillsTotal = skillsResponse.total || 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch skills:', e);
|
||||
}
|
||||
|
||||
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
|
||||
total = pluginsTotal + mcpsTotal + skillsTotal;
|
||||
} else {
|
||||
const response =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
typeFilter === 'all' ? undefined : typeFilter,
|
||||
);
|
||||
|
||||
const data: ApiRespMarketplacePlugins = response;
|
||||
newPlugins = data.plugins
|
||||
.filter((plugin) => {
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(
|
||||
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
|
||||
);
|
||||
})
|
||||
.map(transformToVO);
|
||||
total = data.total;
|
||||
}
|
||||
const data: ApiRespMarketplacePlugins = response;
|
||||
const newPlugins = data.plugins.map(transformToVO);
|
||||
const total = data.total;
|
||||
|
||||
if (reset || page === 1) {
|
||||
setPlugins(newPlugins);
|
||||
@@ -337,11 +236,10 @@ function MarketPageContent({
|
||||
},
|
||||
[
|
||||
searchQuery,
|
||||
componentFilter,
|
||||
selectedTags,
|
||||
pageSize,
|
||||
transformToVO,
|
||||
transformMCPToVO,
|
||||
transformSkillToVO,
|
||||
plugins.length,
|
||||
getCurrentSort,
|
||||
typeFilter,
|
||||
@@ -379,27 +277,13 @@ 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) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
fetchPlugins(1, !!query.trim(), true);
|
||||
fetchPlugins(1, !!query.trim(), true, query);
|
||||
},
|
||||
[fetchPlugins],
|
||||
);
|
||||
@@ -436,7 +320,11 @@ function MarketPageContent({
|
||||
// Handle type filter change
|
||||
const handleTypeFilterChange = useCallback((value: string) => {
|
||||
setTypeFilter(value);
|
||||
if (value !== 'plugin') {
|
||||
setComponentFilter('all');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
setSelectedTags([]);
|
||||
setPlugins([]);
|
||||
|
||||
// Update URL query param to keep it in sync
|
||||
@@ -452,10 +340,27 @@ function MarketPageContent({
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}, []);
|
||||
|
||||
// 当排序选项或类型筛选变化时重新加载数据
|
||||
const handleComponentFilterChange = useCallback((value: string) => {
|
||||
setComponentFilter(value);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
|
||||
if (value !== 'all') {
|
||||
setTypeFilter('plugin');
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('type', 'plugin');
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, !!searchQuery.trim(), true);
|
||||
}, [sortOption, typeFilter]);
|
||||
}, [sortOption, componentFilter, typeFilter]);
|
||||
|
||||
// Tags 筛选变化时重新搜索
|
||||
useEffect(() => {
|
||||
@@ -609,11 +514,11 @@ function MarketPageContent({
|
||||
|
||||
return (
|
||||
<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 and actions */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-center gap-3">
|
||||
<div className="relative flex-1 lg:max-w-xl">
|
||||
{/* Fixed header section with search, sort, and status */}
|
||||
<div className="flex-none px-3 sm:px-4 py-2 sm:py-4 space-y-4 sm:space-y-6 container mx-auto">
|
||||
{/* 搜索、排序和筛选入口 */}
|
||||
<div className="flex w-full items-center justify-center gap-2 sm:gap-3">
|
||||
<div className="relative min-w-0 flex-1 lg:max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
@@ -634,25 +539,13 @@ function MarketPageContent({
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-4 text-sm sm:text-base"
|
||||
className="min-w-0 pl-10 pr-4 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
{headerActions && (
|
||||
<div className="flex items-center gap-2 flex-wrap lg:flex-nowrap lg:flex-shrink-0">
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort, filters and tags in one row */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-0">
|
||||
{/* Sort dropdown */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.sortBy')}:
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="w-[128px] sm:w-40 text-xs sm:text-sm">
|
||||
<SelectTrigger className="w-28 shrink-0 text-xs sm:w-40 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -666,7 +559,10 @@ function MarketPageContent({
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="relative flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative shrink-0 px-3 sm:px-4"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('market.filters.more')}
|
||||
@@ -718,49 +614,79 @@ function MarketPageContent({
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t('market.filterByComponent')}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
spacing={2}
|
||||
size="sm"
|
||||
value={componentFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleComponentFilterChange(value);
|
||||
}}
|
||||
className="flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
{componentOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
aria-label={option.label}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="hidden sm:block w-px h-6 bg-border flex-shrink-0"></div>
|
||||
|
||||
{/* Quick tag filter buttons */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-visible flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => handleTagsChange([])}
|
||||
>
|
||||
{t('market.allExtensions')}
|
||||
</Button>
|
||||
{availableTags.map((tag) => {
|
||||
const selected = selectedTags.includes(tag.tag);
|
||||
return (
|
||||
<Button
|
||||
key={tag.tag}
|
||||
type="button"
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => {
|
||||
const newTags = selected
|
||||
? selectedTags.filter((t) => t !== tag.tag)
|
||||
: [...selectedTags, tag.tag];
|
||||
handleTagsChange(newTags);
|
||||
}}
|
||||
>
|
||||
{tagNames[tag.tag] || tag.tag}
|
||||
{selected && <X className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results stats */}
|
||||
{/* 用真实标签做快速筛选 */}
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:justify-center sm:overflow-visible">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => handleTagsChange([])}
|
||||
>
|
||||
{t('market.allExtensions')}
|
||||
</Button>
|
||||
{availableTags.map((tag) => {
|
||||
const selected = selectedTags.includes(tag.tag);
|
||||
return (
|
||||
<Button
|
||||
key={tag.tag}
|
||||
type="button"
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => {
|
||||
const newTags = selected
|
||||
? selectedTags.filter((t) => t !== tag.tag)
|
||||
: [...selectedTags, tag.tag];
|
||||
handleTagsChange(newTags);
|
||||
}}
|
||||
>
|
||||
{tagNames[tag.tag] || tag.tag}
|
||||
{selected && <X className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 搜索结果统计 */}
|
||||
{total > 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
{searchQuery
|
||||
@@ -770,22 +696,11 @@ function MarketPageContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
{/* Scrollable extension list section */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-3 sm:px-4"
|
||||
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
|
||||
>
|
||||
{/* Recommendation Lists */}
|
||||
{!searchQuery && selectedTags.length === 0 && (
|
||||
<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')} />
|
||||
@@ -805,7 +720,7 @@ function MarketPageContent({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] gap-6 mt-6">
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.pluginId}
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function PluginMarketCardComponent({
|
||||
tagNames?: Record<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleTags, setVisibleTags] = useState(2);
|
||||
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
|
||||
@@ -36,6 +35,18 @@ export default function PluginMarketCardComponent({
|
||||
})();
|
||||
|
||||
const showTypeBadge = cardVO.type;
|
||||
const typeLabel =
|
||||
cardVO.type === 'mcp'
|
||||
? t('market.typeMCP')
|
||||
: cardVO.type === 'skill'
|
||||
? t('market.typeSkill')
|
||||
: t('market.typePlugin');
|
||||
const typeDotClass =
|
||||
cardVO.type === 'mcp'
|
||||
? 'bg-sky-500/70'
|
||||
: cardVO.type === 'skill'
|
||||
? 'bg-emerald-500/70'
|
||||
: 'bg-violet-500/70';
|
||||
|
||||
useEffect(() => {
|
||||
setIconFailed(!cardVO.iconURL);
|
||||
@@ -56,7 +67,10 @@ export default function PluginMarketCardComponent({
|
||||
}
|
||||
const tagWidth = 80;
|
||||
const plusBadgeWidth = 40;
|
||||
const maxTags = Math.max(0, Math.floor((availableForTags - plusBadgeWidth) / tagWidth));
|
||||
const maxTags = Math.max(
|
||||
0,
|
||||
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
|
||||
);
|
||||
if (maxTags >= tags.length) {
|
||||
setVisibleTags(tags.length);
|
||||
} else {
|
||||
@@ -73,11 +87,7 @@ export default function PluginMarketCardComponent({
|
||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] relative">
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0 flex-1 overflow-hidden">
|
||||
{iconFailed ? (
|
||||
@@ -96,15 +106,22 @@ export default function PluginMarketCardComponent({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 pr-1 overflow-hidden">
|
||||
<div className="flex flex-col items-start justify-start w-full min-w-0">
|
||||
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">{cardVO.pluginId}</div>
|
||||
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||
{cardVO.pluginId}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">{cardVO.label}</div>
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
{isDeprecated && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
|
||||
@@ -113,7 +130,10 @@ export default function PluginMarketCardComponent({
|
||||
<Info className="w-2.5 h-2.5" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-[240px] text-xs"
|
||||
>
|
||||
{t('market.deprecatedTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -122,19 +142,12 @@ export default function PluginMarketCardComponent({
|
||||
{showTypeBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 gap-0.5 ${
|
||||
cardVO.type === 'mcp'
|
||||
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
|
||||
: cardVO.type === 'skill'
|
||||
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
|
||||
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
|
||||
}`}
|
||||
className="h-4 max-w-[4.5rem] flex-shrink-0 gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[0.58rem] font-normal text-muted-foreground"
|
||||
>
|
||||
{cardVO.type === 'mcp'
|
||||
? 'MCP'
|
||||
: cardVO.type === 'skill'
|
||||
? t('common.skill')
|
||||
: t('market.typePlugin')}
|
||||
<span
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${typeDotClass}`}
|
||||
/>
|
||||
<span className="truncate">{typeLabel}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,26 +158,68 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
|
||||
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t('market.install')}
|
||||
aria-label={t('market.install')}
|
||||
className="h-7 w-7 rounded-md text-blue-600 hover:bg-blue-50 hover:text-blue-700 dark:text-blue-400 dark:hover:bg-blue-950/40 dark:hover:text-blue-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onInstall) {
|
||||
onInstall(cardVO);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t('market.viewDetails')}
|
||||
aria-label={t('market.viewDetails')}
|
||||
className="h-7 w-7 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(pluginDetailUrl, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
{cardVO.githubURL && (
|
||||
<svg
|
||||
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] dark:hover:text-[#c0c0c0] flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="GitHub"
|
||||
aria-label="GitHub"
|
||||
className="h-7 w-7 rounded-md text-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(cardVO.githubURL, '_blank');
|
||||
}}
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={bottomRef} className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden">
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||
<svg
|
||||
@@ -205,7 +260,9 @@ export default function PluginMarketCardComponent({
|
||||
<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 max-w-[5rem]">{tagNames[tag] || tag}</span>
|
||||
<span className="truncate max-w-[5rem]">
|
||||
{tagNames[tag] || tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags > 0 && (
|
||||
@@ -234,42 +291,6 @@ export default function PluginMarketCardComponent({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onInstall) {
|
||||
onInstall(cardVO);
|
||||
}
|
||||
}}
|
||||
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('market.install')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(pluginDetailUrl, '_blank');
|
||||
}}
|
||||
variant="outline"
|
||||
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('market.viewDetails')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,108 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public searchMarketplaceExtensions(data: {
|
||||
query?: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
type_filter?: string;
|
||||
component_filter?: string;
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
return this.post<{ extensions: PluginV4[]; total: number }>(
|
||||
'/api/v1/marketplace/extensions/search',
|
||||
data,
|
||||
)
|
||||
.then((resp) => ({
|
||||
plugins: resp?.extensions || [],
|
||||
total: resp?.total || 0,
|
||||
}))
|
||||
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
|
||||
}
|
||||
|
||||
private async searchMarketplaceExtensionsLegacy(data: {
|
||||
query?: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
type_filter?: string;
|
||||
component_filter?: string;
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
const query = data.query || '';
|
||||
|
||||
if (
|
||||
data.type_filter === 'plugin' ||
|
||||
data.type_filter === 'mcp' ||
|
||||
data.type_filter === 'skill' ||
|
||||
data.component_filter
|
||||
) {
|
||||
return this.searchMarketplacePlugins(
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
data.component_filter,
|
||||
data.tags_filter,
|
||||
data.component_filter ? 'plugin' : data.type_filter,
|
||||
).catch((error) => {
|
||||
if (data.type_filter === 'mcp' || data.type_filter === 'skill') {
|
||||
return { plugins: [], total: 0 };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
const [pluginsResp, mcpsResp, skillsResp] = await Promise.all([
|
||||
this.searchMarketplacePlugins(
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
'plugin',
|
||||
).catch(() => ({ plugins: [], total: 0 })),
|
||||
this.searchMarketplacePlugins(
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
'mcp',
|
||||
).catch(() => ({ plugins: [], total: 0 })),
|
||||
this.searchMarketplacePlugins(
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
'skill',
|
||||
).catch(() => ({ plugins: [], total: 0 })),
|
||||
]);
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
...(pluginsResp.plugins || []),
|
||||
...(mcpsResp.plugins || []),
|
||||
...(skillsResp.plugins || []),
|
||||
],
|
||||
total:
|
||||
(pluginsResp.total || 0) +
|
||||
(mcpsResp.total || 0) +
|
||||
(skillsResp.total || 0),
|
||||
};
|
||||
}
|
||||
|
||||
public getPluginDetail(
|
||||
author: string,
|
||||
pluginName: string,
|
||||
@@ -120,6 +222,14 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;
|
||||
}
|
||||
|
||||
public getMCPMarketplaceIconURL(author: string, name: string): string {
|
||||
return `${this.baseURL}/api/v1/marketplace/mcps/${author}/${name}/resources/icon`;
|
||||
}
|
||||
|
||||
public getSkillMarketplaceIconURL(author: string, name: string): string {
|
||||
return `${this.baseURL}/api/v1/marketplace/skills/${author}/${name}/resources/icon`;
|
||||
}
|
||||
|
||||
public getPluginAssetURL(
|
||||
author: string,
|
||||
pluginName: string,
|
||||
|
||||
@@ -664,11 +664,12 @@ const enUS = {
|
||||
deprecatedTooltip:
|
||||
'Please install the corresponding Knowledge Engine plugin.',
|
||||
filters: {
|
||||
allFormats: 'All Types',
|
||||
more: 'More',
|
||||
advancedTitle: 'Advanced Filters',
|
||||
advancedDescription: 'Filter by extension type',
|
||||
technicalType: 'Technical Type',
|
||||
allFormats: 'All formats',
|
||||
more: 'Filters',
|
||||
advancedTitle: 'Advanced filters',
|
||||
advancedDescription:
|
||||
'Most users do not need these. Use them only when you know the extension format you want.',
|
||||
technicalType: 'Extension format',
|
||||
},
|
||||
allExtensions: 'All Extensions',
|
||||
tags: {
|
||||
|
||||
@@ -640,11 +640,12 @@ const zhHans = {
|
||||
noTags: '暂无标签',
|
||||
},
|
||||
filters: {
|
||||
allFormats: '全部类型',
|
||||
more: '更多',
|
||||
allFormats: '全部格式',
|
||||
more: '筛选',
|
||||
advancedTitle: '高级筛选',
|
||||
advancedDescription: '按扩展类型筛选',
|
||||
technicalType: '技术类型',
|
||||
advancedDescription:
|
||||
'普通用户通常不需要选择这些类型;仅在你明确知道扩展格式时使用。',
|
||||
technicalType: '扩展格式',
|
||||
},
|
||||
allExtensions: '全部扩展',
|
||||
viewDetails: '查看详情',
|
||||
|
||||
Reference in New Issue
Block a user