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:
yujiangping
2026-03-11 18:24:46 +08:00
parent 01da2e3eee
commit 59618457df
5 changed files with 84 additions and 26 deletions

View File

@@ -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',

View File

@@ -1816,6 +1816,10 @@ export const zh = {
marketRefresh: '刷新',
marketConfigBtn: '配置',
marketConfigConnection: '配置连接',
marketNoData: '暂无数据',
marketNoDataDesc: '该市场暂时没有可用的服务',
marketNoSearchResult: '无搜索结果',
marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词',
marketNoServices: '暂无可用的 MCP 服务',
marketNotConnected: '尚未连接此市场',
marketNoServicesDesc: '该市场暂时没有可用的服务',

View File

@@ -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>
)}

View File

@@ -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);

View File

@@ -146,4 +146,5 @@ export interface MarketQuery {
mcp_market_config_id?: string;
page?: number;
pagesize?: number;
keywords?: string;
}