♻️ refactor(ai): 拆分 MCP 客户端接入面板

- 将客户端选择和状态详情拆为独立组件

- 保留本机安装、远程桥接和快速配置展示行为

- 补充选择区和状态区渲染测试
This commit is contained in:
Syngnat
2026-06-11 19:47:11 +08:00
parent e6d2685521
commit c1d27448bc
5 changed files with 511 additions and 312 deletions

View File

@@ -1,24 +1,16 @@
import React from 'react';
import { Button } from 'antd';
import { CheckCircleFilled, CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import {
buildRemoteMCPClientQuickStart,
isMCPClientKey,
isRemoteMCPClientStatus,
type MCPClientKey,
} from '../../utils/mcpClientInstallStatus';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPRemoteQuickStartPanel from './AIMCPRemoteQuickStartPanel';
import AIMCPClientSelectorPanel from './AIMCPClientSelectorPanel';
import AIMCPClientStatusPanel from './AIMCPClientStatusPanel';
import {
getMCPClientDetectionSummary,
getMCPClientInstallStateLabel,
getMCPClientOptionSummary,
getMCPClientStatusSummary,
getMCPClientStatusTone,
getSelectedMCPClientStateLine,
resolveMCPClientCommandName,
resolveMCPClientInstallActionLabel,
} from './mcpClientInstallPanelState';
@@ -58,329 +50,83 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
onInstall,
}) => {
const selectedIsRemoteClient = isRemoteMCPClientStatus(selectedStatus);
const remoteQuickStart = selectedIsRemoteClient
? buildRemoteMCPClientQuickStart(selectedStatus)
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${darkMode ? 'rgba(96,165,250,0.16)' : 'rgba(96,165,250,0.18)'}`,
background: darkMode ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.05)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
GoNavi MCP Claude Code / Codex / OpenClaw / Hermans GoNavi
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
Claude Code Codex MCP OpenClawHermans Agent
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
1 CLI Agent MCP /访 GoNavi
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 10,
}}
>
{[
{ step: '1', title: '选择目标客户端', detail: '本机 Claude/Codex 可自动安装OpenClaw/Hermans 走远程接入说明。' },
{ step: '2', title: '写入或复制配置', detail: '自动安装只改用户级 MCP 配置;远程 Agent 复制桥接说明。' },
{ step: '3', title: '重启或配置目标端', detail: '本机 CLI 重启后验证;云端 Agent 配置远程 MCP 地址后验证。' },
].map((item) => (
<div
key={item.step}
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)',
display: 'flex',
flexDirection: 'column',
gap: 5,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 22,
height: 22,
borderRadius: 999,
background: overlayTheme.selectedText,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}
>
{item.step}
</div>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>{item.title}</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{item.detail}</div>
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div
role="radiogroup"
aria-label="选择要安装 GoNavi MCP 的外部客户端"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}
>
{statuses.map((status) => {
const client = isMCPClientKey(status.client) ? status.client : 'claude-code';
const remoteClient = isRemoteMCPClientStatus(status);
const active = selectedClient === client;
const tone = getMCPClientStatusTone(status, darkMode);
return (
<button
key={status.client}
type="button"
role="radio"
aria-checked={active}
onClick={() => onSelectClient(client)}
style={{
padding: '14px 16px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
textAlign: 'left',
minHeight: 98,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
width: 22,
height: 22,
borderRadius: 999,
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.16)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, minWidth: 0 }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
width: 80,
textAlign: 'center',
flexShrink: 0,
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getMCPClientOptionSummary(status)}
</div>
<div style={{ fontSize: 12, color: active ? overlayTheme.selectedText : overlayTheme.mutedText, lineHeight: 1.6, fontWeight: 700 }}>
{getMCPClientInstallStateLabel(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{active
? (remoteClient ? '当前已选中,将复制远程接入说明。' : '当前已选中,将只对这个客户端执行写入或更新。')
: (remoteClient ? '点击后查看远程接入方式。' : '点击后切换到这个客户端。')}
</div>
</button>
);
})}
</div>
</div>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 6,
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${darkMode ? 'rgba(96,165,250,0.16)' : 'rgba(96,165,250,0.18)'}`,
background: darkMode ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.05)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
GoNavi MCP Claude Code / Codex / OpenClaw / Hermans GoNavi
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.displayName || '未选择客户端'}
Claude Code Codex MCP OpenClawHermans Agent
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{getMCPClientStatusSummary(selectedStatus)}
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getMCPClientStatusTone(selectedStatus, darkMode).color,
background: getMCPClientStatusTone(selectedStatus, darkMode).bg,
}}
>
{getMCPClientStatusTone(selectedStatus, darkMode).label}
</div>
</div>
<AIMCPClientSelectorPanel
statuses={statuses}
selectedClient={selectedClient}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
statusLoading={statusLoading}
onSelectClient={onSelectClient}
/>
<AIMCPClientStatusPanel
selectedStatus={selectedStatus}
selectedCommandText={selectedCommandText}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
statusLoading={statusLoading}
onRefreshStatus={onRefreshStatus}
onCopyConfigPath={onCopyConfigPath}
onCopyLaunchCommand={onCopyLaunchCommand}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getMCPClientDetectionSummary(selectedStatus)}
{!selectedIsRemoteClient && (
<>
{' '}
GoNavi
</>
)}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{getSelectedMCPClientStateLine(selectedStatus)}
</div>
{selectedIsRemoteClient && (
<div
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${darkMode ? 'rgba(56,189,248,0.22)' : 'rgba(14,165,233,0.18)'}`,
background: darkMode ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.7,
}}
>
Windows GoNavi Agent schema-only MCP DDL execute_sql使 GoNavi Streamable HTTP token
</div>
)}
{remoteQuickStart && (
<AIMCPRemoteQuickStartPanel
quickStart={remoteQuickStart}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
/>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
CLI {selectedIsRemoteClient
? `远程 Agent 不需要检测本机 ${resolveMCPClientCommandName(selectedStatus)} 命令`
: selectedStatus?.clientDetected
? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}`
: `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`}
</div>
{selectedStatus?.clientPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.clientPath}
</div>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到接入状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.configPath}
</div>
)}
{selectedCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
style={{ borderRadius: 8 }}
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, width: 208, maxWidth: '100%', height: 40 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedStatus?.configPath}
onClick={onCopyConfigPath}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedCommandText}
onClick={onCopyLaunchCommand}
style={{ borderRadius: 8 }}
>
{resolveMCPClientInstallActionLabel(selectedStatus)}
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getMCPClientDetectionSummary(selectedStatus)}
{!selectedIsRemoteClient && (
<>
{' '}
GoNavi
</>
)}
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, width: 208, maxWidth: '100%', height: 40 }}
>
{resolveMCPClientInstallActionLabel(selectedStatus)}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientSelectorPanel from './AIMCPClientSelectorPanel';
describe('AIMCPClientSelectorPanel', () => {
it('renders local install and remote bridge choices with clear state labels', () => {
const markup = renderToStaticMarkup(
<AIMCPClientSelectorPanel
statuses={[
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: true,
clientDetected: true,
clientCommand: 'codex',
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
},
{
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi。',
},
]}
selectedClient="openclaw"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBorder="rgba(0,0,0,0.08)"
statusLoading={false}
onSelectClient={() => {}}
/>,
);
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('选择目标客户端');
expect(markup).toContain('写入或复制配置');
expect(markup).toContain('重启或配置目标端');
expect(markup).toContain('Codex');
expect(markup).toContain('已接入');
expect(markup).toContain('OpenClaw');
expect(markup).toContain('远程桥接');
expect(markup).toContain('当前已选中,将复制远程接入说明');
expect(markup).toContain('云端 Agent');
});
});

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { CheckCircleFilled } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import {
isMCPClientKey,
isRemoteMCPClientStatus,
type MCPClientKey,
} from '../../utils/mcpClientInstallStatus';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import {
getMCPClientInstallStateLabel,
getMCPClientOptionSummary,
getMCPClientStatusTone,
} from './mcpClientInstallPanelState';
interface AIMCPClientSelectorPanelProps {
statuses: AIMCPClientInstallStatus[];
selectedClient: MCPClientKey;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBorder: string;
statusLoading: boolean;
onSelectClient: (client: MCPClientKey) => void;
}
const MCP_CLIENT_INSTALL_STEPS = [
{ step: '1', title: '选择目标客户端', detail: '本机 Claude/Codex 可自动安装OpenClaw/Hermans 走远程接入说明。' },
{ step: '2', title: '写入或复制配置', detail: '自动安装只改用户级 MCP 配置;远程 Agent 复制桥接说明。' },
{ step: '3', title: '重启或配置目标端', detail: '本机 CLI 重启后验证;云端 Agent 配置远程 MCP 地址后验证。' },
];
const AIMCPClientSelectorPanel: React.FC<AIMCPClientSelectorPanelProps> = ({
statuses,
selectedClient,
darkMode,
overlayTheme,
cardBorder,
statusLoading,
onSelectClient,
}) => (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
1 CLI Agent MCP /访 GoNavi
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 10,
}}
>
{MCP_CLIENT_INSTALL_STEPS.map((item) => (
<div
key={item.step}
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)',
display: 'flex',
flexDirection: 'column',
gap: 5,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 22,
height: 22,
borderRadius: 999,
background: overlayTheme.selectedText,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}
>
{item.step}
</div>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>{item.title}</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{item.detail}</div>
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div
role="radiogroup"
aria-label="选择要安装 GoNavi MCP 的外部客户端"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}
>
{statuses.map((status) => {
const client = isMCPClientKey(status.client) ? status.client : 'claude-code';
const remoteClient = isRemoteMCPClientStatus(status);
const active = selectedClient === client;
const tone = getMCPClientStatusTone(status, darkMode);
return (
<button
key={status.client}
type="button"
role="radio"
aria-checked={active}
onClick={() => onSelectClient(client)}
style={{
padding: '14px 16px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
textAlign: 'left',
minHeight: 98,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
width: 22,
height: 22,
borderRadius: 999,
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.16)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, minWidth: 0 }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
width: 80,
textAlign: 'center',
flexShrink: 0,
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getMCPClientOptionSummary(status)}
</div>
<div style={{ fontSize: 12, color: active ? overlayTheme.selectedText : overlayTheme.mutedText, lineHeight: 1.6, fontWeight: 700 }}>
{getMCPClientInstallStateLabel(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{active
? (remoteClient ? '当前已选中,将复制远程接入说明。' : '当前已选中,将只对这个客户端执行写入或更新。')
: (remoteClient ? '点击后查看远程接入方式。' : '点击后切换到这个客户端。')}
</div>
</button>
);
})}
</div>
</div>
</>
);
export default AIMCPClientSelectorPanel;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientStatusPanel from './AIMCPClientStatusPanel';
describe('AIMCPClientStatusPanel', () => {
it('renders selected remote client status, boundary notes, and remote quick-start actions', () => {
const markup = renderToStaticMarkup(
<AIMCPClientStatusPanel
selectedStatus={{
client: 'hermans',
displayName: 'Hermans',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'hermans',
message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
}}
selectedCommandText=""
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBorder="rgba(0,0,0,0.08)"
statusLoading={false}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
/>,
);
expect(markup).toContain('已选客户端状态');
expect(markup).toContain('当前目标客户端Hermans');
expect(markup).toContain('需要通过远程 MCP 桥接调用当前 GoNavi');
expect(markup).toContain('远程接入边界');
expect(markup).toContain('不注册 execute_sql');
expect(markup).toContain('Hermans 远程 MCP 快速配置');
expect(markup).toContain('CLI 检测:远程 Agent 不需要检测本机 hermans 命令');
expect(markup).toContain('刷新状态');
expect(markup).toContain('复制配置路径');
expect(markup).toContain('复制启动命令');
});
});

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { Button } from 'antd';
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import {
buildRemoteMCPClientQuickStart,
isRemoteMCPClientStatus,
} from '../../utils/mcpClientInstallStatus';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPRemoteQuickStartPanel from './AIMCPRemoteQuickStartPanel';
import {
getMCPClientStatusSummary,
getMCPClientStatusTone,
getSelectedMCPClientStateLine,
resolveMCPClientCommandName,
} from './mcpClientInstallPanelState';
interface AIMCPClientStatusPanelProps {
selectedStatus?: AIMCPClientInstallStatus;
selectedCommandText: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBorder: string;
statusLoading: boolean;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
}
const AIMCPClientStatusPanel: React.FC<AIMCPClientStatusPanelProps> = ({
selectedStatus,
selectedCommandText,
darkMode,
overlayTheme,
cardBorder,
statusLoading,
onRefreshStatus,
onCopyConfigPath,
onCopyLaunchCommand,
}) => {
const selectedIsRemoteClient = isRemoteMCPClientStatus(selectedStatus);
const remoteQuickStart = selectedIsRemoteClient
? buildRemoteMCPClientQuickStart(selectedStatus)
: null;
return (
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.displayName || '未选择客户端'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{getMCPClientStatusSummary(selectedStatus)}
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getMCPClientStatusTone(selectedStatus, darkMode).color,
background: getMCPClientStatusTone(selectedStatus, darkMode).bg,
}}
>
{getMCPClientStatusTone(selectedStatus, darkMode).label}
</div>
)}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{getSelectedMCPClientStateLine(selectedStatus)}
</div>
{selectedIsRemoteClient && (
<div
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${darkMode ? 'rgba(56,189,248,0.22)' : 'rgba(14,165,233,0.18)'}`,
background: darkMode ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.7,
}}
>
Windows GoNavi Agent schema-only MCP DDL execute_sql使 GoNavi Streamable HTTP token
</div>
)}
{remoteQuickStart && (
<AIMCPRemoteQuickStartPanel
quickStart={remoteQuickStart}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
/>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
CLI {selectedIsRemoteClient
? `远程 Agent 不需要检测本机 ${resolveMCPClientCommandName(selectedStatus)} 命令`
: selectedStatus?.clientDetected
? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}`
: `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`}
</div>
{selectedStatus?.clientPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.clientPath}
</div>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到接入状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.configPath}
</div>
)}
{selectedCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedStatus?.configPath}
onClick={onCopyConfigPath}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedCommandText}
onClick={onCopyLaunchCommand}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
);
};
export default AIMCPClientStatusPanel;