feat(web): add search functionality and empty states to MCP market
- Add search input with debouncing (500ms) to filter MCP services by keywords - Implement server-side search via keywords parameter in getMarketMCPs API call - Add new i18n strings for empty states: marketNoData, marketNoDataDesc, marketNoSearchResult, marketNoSearchResultDesc - Replace client-side filtering with server-side search for better performance - Update Empty component display to show different messages for no data vs no search results - Remove BodyWrapper component and implement custom empty state handling - Add searchTimerRef to manage debounce timer lifecycle - Update loadMore callback to include searchKeyword parameter for pagination consistency - Add allowClear prop to search input for better UX - Remove conditional rendering of search input to keep it always visible
This commit is contained in:
@@ -1820,6 +1820,10 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
marketRefresh: 'Refresh',
|
||||
marketConfigBtn: 'Configure',
|
||||
marketConfigConnection: 'Configure Connection',
|
||||
marketNoData: 'No Data',
|
||||
marketNoDataDesc: 'This market currently has no available services',
|
||||
marketNoSearchResult: 'No Search Results',
|
||||
marketNoSearchResultDesc: 'No matching services found, please try other keywords',
|
||||
marketNoServices: 'No MCP Services Available',
|
||||
marketNotConnected: 'Not Connected to This Market',
|
||||
marketNoServicesDesc: 'This market currently has no available services',
|
||||
|
||||
@@ -1816,6 +1816,10 @@ export const zh = {
|
||||
marketRefresh: '刷新',
|
||||
marketConfigBtn: '配置',
|
||||
marketConfigConnection: '配置连接',
|
||||
marketNoData: '暂无数据',
|
||||
marketNoDataDesc: '该市场暂时没有可用的服务',
|
||||
marketNoSearchResult: '无搜索结果',
|
||||
marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词',
|
||||
marketNoServices: '暂无可用的 MCP 服务',
|
||||
marketNotConnected: '尚未连接此市场',
|
||||
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { McpServiceModalRef } from './types';
|
||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||
import Empty from '@/components/Empty/index'
|
||||
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper';
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -75,6 +74,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const searchTimerRef = useRef<number | null>(null);
|
||||
|
||||
// 获取市场数据
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
fetchMarketData();
|
||||
}, [message]);
|
||||
|
||||
const fetchMcpList = async (sourceId: string, page = 1, append = false) => {
|
||||
const fetchMcpList = async (sourceId: string, page = 1, append = false, keywords = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let configId = configIdMap[sourceId];
|
||||
@@ -139,7 +139,12 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
const allTools: any = await getTools({ tool_type: 'mcp' });
|
||||
const toolsList = Array.isArray(allTools) ? allTools : [];
|
||||
|
||||
const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page, pagesize: pageSize });
|
||||
const res: any = await getMarketMCPs({
|
||||
mcp_market_config_id: configId,
|
||||
page,
|
||||
pagesize: pageSize,
|
||||
...(keywords ? { keywords } : {})
|
||||
});
|
||||
if (res?.items && Array.isArray(res.items)) {
|
||||
// 标记已激活和已入库的 MCP
|
||||
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
||||
@@ -176,8 +181,46 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!selectedSource || loading) return;
|
||||
fetchMcpList(selectedSource, currentPage + 1, true);
|
||||
}, [selectedSource, currentPage, loading]);
|
||||
fetchMcpList(selectedSource, currentPage + 1, true, searchKeyword);
|
||||
}, [selectedSource, currentPage, loading, searchKeyword]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
|
||||
// 如果清空搜索框,恢复原始列表
|
||||
if (!value.trim()) {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新加载原始列表
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新搜索
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, value);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSelectSource = async (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
@@ -313,12 +356,6 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
if (!source) return null;
|
||||
|
||||
const mcpList = mcpCache[selectedSource] || [];
|
||||
const filteredList = mcpList.filter(mcp => {
|
||||
const name = getLocaleField(mcp, 'name');
|
||||
const desc = getLocaleField(mcp, 'description');
|
||||
return name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
desc.toLowerCase().includes(searchKeyword.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -358,15 +395,17 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
{t('tool.marketRefresh')}
|
||||
</Button>
|
||||
)}
|
||||
{mcpList.length > 0 && (
|
||||
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('tool.marketSearchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
{t('tool.marketConfigBtn')}
|
||||
@@ -378,10 +417,18 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
</div>
|
||||
|
||||
<div className="rb:mt-6">
|
||||
<BodyWrapper loading={loading} empty={mcpList.length === 0}>
|
||||
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
||||
{!loading && mcpList.length === 0 ? (
|
||||
<Empty
|
||||
url={pageEmptyIcon}
|
||||
title={searchKeyword ? t('tool.marketNoSearchResult') : t('tool.marketNoData')}
|
||||
subTitle={searchKeyword ? t('tool.marketNoSearchResultDesc') : t('tool.marketNoDataDesc')}
|
||||
size={200}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
dataLength={filteredList.length}
|
||||
dataLength={mcpList.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
||||
@@ -394,7 +441,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||
}}
|
||||
>
|
||||
{filteredList.map(mcp => (
|
||||
{mcpList.map(mcp => (
|
||||
<div
|
||||
key={mcp.id}
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:pb-2 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
@@ -453,8 +500,8 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
</BodyWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -515,9 +562,9 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
||||
{/* <span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
||||
{source.mcp_count}
|
||||
</span>
|
||||
</span> */}
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
||||
)}
|
||||
|
||||
@@ -132,12 +132,14 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||
request.then((res: any) => {
|
||||
message.success(t('common.saveSuccess'));
|
||||
testConnection(res.tool_id || editVo?.id)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh()
|
||||
})
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh();
|
||||
|
||||
// 在后台测试连接,不阻塞用户操作
|
||||
testConnection(res.tool_id || editVo?.id).catch((err) => {
|
||||
console.error('测试连接失败:', err);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
|
||||
@@ -146,4 +146,5 @@ export interface MarketQuery {
|
||||
mcp_market_config_id?: string;
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
keywords?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user