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',
|
marketRefresh: 'Refresh',
|
||||||
marketConfigBtn: 'Configure',
|
marketConfigBtn: 'Configure',
|
||||||
marketConfigConnection: 'Configure Connection',
|
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',
|
marketNoServices: 'No MCP Services Available',
|
||||||
marketNotConnected: 'Not Connected to This Market',
|
marketNotConnected: 'Not Connected to This Market',
|
||||||
marketNoServicesDesc: 'This market currently has no available services',
|
marketNoServicesDesc: 'This market currently has no available services',
|
||||||
|
|||||||
@@ -1816,6 +1816,10 @@ export const zh = {
|
|||||||
marketRefresh: '刷新',
|
marketRefresh: '刷新',
|
||||||
marketConfigBtn: '配置',
|
marketConfigBtn: '配置',
|
||||||
marketConfigConnection: '配置连接',
|
marketConfigConnection: '配置连接',
|
||||||
|
marketNoData: '暂无数据',
|
||||||
|
marketNoDataDesc: '该市场暂时没有可用的服务',
|
||||||
|
marketNoSearchResult: '无搜索结果',
|
||||||
|
marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词',
|
||||||
marketNoServices: '暂无可用的 MCP 服务',
|
marketNoServices: '暂无可用的 MCP 服务',
|
||||||
marketNotConnected: '尚未连接此市场',
|
marketNotConnected: '尚未连接此市场',
|
||||||
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { McpServiceModalRef } from './types';
|
|||||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||||
import Empty from '@/components/Empty/index'
|
import Empty from '@/components/Empty/index'
|
||||||
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
||||||
import BodyWrapper from '@/components/Empty/BodyWrapper';
|
|
||||||
interface MarketSource {
|
interface MarketSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +74,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
const searchTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 获取市场数据
|
// 获取市场数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,7 +109,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
fetchMarketData();
|
fetchMarketData();
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
const fetchMcpList = async (sourceId: string, page = 1, append = false) => {
|
const fetchMcpList = async (sourceId: string, page = 1, append = false, keywords = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let configId = configIdMap[sourceId];
|
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 allTools: any = await getTools({ tool_type: 'mcp' });
|
||||||
const toolsList = Array.isArray(allTools) ? allTools : [];
|
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)) {
|
if (res?.items && Array.isArray(res.items)) {
|
||||||
// 标记已激活和已入库的 MCP
|
// 标记已激活和已入库的 MCP
|
||||||
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
||||||
@@ -176,8 +181,46 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!selectedSource || loading) return;
|
if (!selectedSource || loading) return;
|
||||||
fetchMcpList(selectedSource, currentPage + 1, true);
|
fetchMcpList(selectedSource, currentPage + 1, true, searchKeyword);
|
||||||
}, [selectedSource, currentPage, loading]);
|
}, [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) => {
|
const handleSelectSource = async (sourceId: string) => {
|
||||||
setSelectedSource(sourceId);
|
setSelectedSource(sourceId);
|
||||||
@@ -313,12 +356,6 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
const mcpList = mcpCache[selectedSource] || [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -358,15 +395,17 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
{t('tool.marketRefresh')}
|
{t('tool.marketRefresh')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{mcpList.length > 0 && (
|
|
||||||
<Input
|
<Input
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
placeholder={t('tool.marketSearchPlaceholder')}
|
placeholder={t('tool.marketSearchPlaceholder')}
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||||
{t('tool.marketConfigBtn')}
|
{t('tool.marketConfigBtn')}
|
||||||
@@ -378,10 +417,18 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rb:mt-6">
|
<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)]">
|
<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
|
<InfiniteScroll
|
||||||
dataLength={filteredList.length}
|
dataLength={mcpList.length}
|
||||||
next={loadMore}
|
next={loadMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
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))'
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredList.map(mcp => (
|
{mcpList.map(mcp => (
|
||||||
<div
|
<div
|
||||||
key={mcp.id}
|
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"
|
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>
|
</div>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BodyWrapper>
|
|
||||||
</div>
|
</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">
|
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||||
{source.name}
|
{source.name}
|
||||||
</span>
|
</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}
|
{source.mcp_count}
|
||||||
</span>
|
</span> */}
|
||||||
{source.connected && (
|
{source.connected && (
|
||||||
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
<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)
|
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||||
request.then((res: any) => {
|
request.then((res: any) => {
|
||||||
message.success(t('common.saveSuccess'));
|
message.success(t('common.saveSuccess'));
|
||||||
testConnection(res.tool_id || editVo?.id)
|
setLoading(false);
|
||||||
.finally(() => {
|
handleClose();
|
||||||
setLoading(false);
|
refresh();
|
||||||
handleClose();
|
|
||||||
refresh()
|
// 在后台测试连接,不阻塞用户操作
|
||||||
})
|
testConnection(res.tool_id || editVo?.id).catch((err) => {
|
||||||
|
console.error('测试连接失败:', err);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -146,4 +146,5 @@ export interface MarketQuery {
|
|||||||
mcp_market_config_id?: string;
|
mcp_market_config_id?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
|
keywords?: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user