feat: 完善 MCP 接入体验与协议层稳定性

接入体验:
- 客户端选择器覆盖 7 个客户端(Claude Desktop/Code、Cursor、Cline、
  Windsurf、Codex、GitHub Copilot),自动生成对应配置
- API Key 一键含 key 复制(密码验证后写入剪贴板)
- 公开 /mcp/:projectId/info + Test connection 按钮
- 项目新增 Usage 标签(每日柱状图 + 工具调用分布)
- 导入完成后引导跳转到 MCP 配置

协议层稳定性:
- session 超时清理(5 分钟扫描 / 30 分钟 idle 关闭)
- 错误响应标准化为 { error: { code, message } }
- 调用日志捕获错误信息 + 4KB payload 截断
- McpCallLog 增加 errorMessage 字段(migration)

抽出可复用模块:PasswordRevealPrompt 组件、BarChart 组件、
trends helper(daysWindowStart + getDailyTrends)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 12:52:47 +08:00
parent 8d7692a42d
commit 95198e6a07
22 changed files with 851 additions and 241 deletions

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
export type BarChartPoint = {
key: string;
label: string;
value: number;
/** Optional secondary value rendered as an overlay (e.g., errors stacked on calls). */
secondary?: number;
tooltip: ReactNode;
};
export default function BarChart({ points, height = 180, emptyLabel }: { points: BarChartPoint[]; height?: number; emptyLabel: string }) {
if (points.length === 0) {
return <div style={{ height }} className="flex items-center justify-center text-[13px] text-text-muted">{emptyLabel}</div>;
}
const max = Math.max(...points.map((p) => p.value), 1);
return (
<div className="flex items-end gap-1.5" style={{ height }}>
{points.map((point) => {
const barHeight = (point.value / max) * 100;
const overlayHeight = point.secondary !== undefined && point.value > 0
? (point.secondary / point.value) * barHeight
: 0;
return (
<div key={point.key} className="flex-1 flex flex-col items-center gap-1 group relative">
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
{point.tooltip}
</div>
</div>
<div className="w-full flex-1 flex items-end relative">
<div className="w-full rounded-t-md bg-accent/70 group-hover:bg-accent transition-colors" style={{ height: `${Math.max(barHeight, 2)}%` }} />
{overlayHeight > 0 && (
<div className="w-full absolute bottom-0 rounded-t-md bg-danger/70" style={{ height: `${overlayHeight}%` }} />
)}
</div>
<span className="text-[9px] text-text-muted">{point.label}</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { apiFetch } from '../lib/api';
import { useI18n } from '../lib/i18n';
import { useAuth } from '../lib/auth';
type Props = {
open: boolean;
promptText: string;
onCancel: () => void;
onReveal: (apiKey: string) => void;
onSetPasswordClick: () => void;
};
export default function PasswordRevealPrompt({ open, promptText, onCancel, onReveal, onSetPasswordClick }: Props) {
const { t } = useI18n();
const { user } = useAuth();
const hasPassword = user?.hasPassword !== false;
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
if (!open) return null;
const submit = async () => {
setLoading(true);
setError('');
try {
const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', {
method: 'POST', body: JSON.stringify({ password }),
});
onReveal(data.apiKey);
setPassword('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
} finally {
setLoading(false);
}
};
if (!hasPassword) {
return (
<div className="p-3.5 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
<div className="flex gap-2">
<button onClick={onSetPasswordClick} className="btn-primary text-[13px] py-1.5">
{t('dashboard.settings.setPasswordAction')}
</button>
<button onClick={onCancel} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</div>
);
}
return (
<div className="p-3.5 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
<p className="text-[13px] text-text-secondary">{promptText}</p>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && password) submit(); }}
className="input-base"
placeholder={t('dashboard.settings.currentPassword')}
autoFocus
/>
{error && <p className="text-[12px] text-danger">{error}</p>}
<div className="flex gap-2">
<button onClick={submit} disabled={loading || !password} className="btn-primary text-[13px] py-1.5">
{loading ? t('dashboard.settings.verifying') : t('common.confirm')}
</button>
<button onClick={onCancel} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useAuth } from '../lib/auth';
import { useI18n } from '../lib/i18n';
import { apiFetch } from '../lib/api';
import ConfirmDialog from './ConfirmDialog';
import PasswordRevealPrompt from './PasswordRevealPrompt';
type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
@@ -37,10 +38,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
const [keyError, setKeyError] = useState('');
const [keyCopied, setKeyCopied] = useState(false);
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
const [showPasswordPrompt, setShowPasswordPrompt] = useState<'reveal' | 'copy' | null>(null);
const [verifyPassword, setVerifyPassword] = useState('');
const [verifyError, setVerifyError] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
const [passwordPromptMode, setPasswordPromptMode] = useState<'reveal' | 'copy' | null>(null);
useEffect(() => {
const el = dialogRef.current;
@@ -61,9 +59,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
setRevealedKey(null);
setKeyError('');
setKeyCopied(false);
setShowPasswordPrompt(null);
setVerifyPassword('');
setVerifyError('');
setPasswordPromptMode(null);
}
}, [open, user?.name]);
@@ -164,27 +160,15 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
const hasPassword = user?.hasPassword !== false;
const handleVerifyAndAction = async () => {
setVerifyLoading(true);
setVerifyError('');
try {
const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', {
method: 'POST', body: JSON.stringify({ password: verifyPassword }),
});
if (showPasswordPrompt === 'copy') {
navigator.clipboard.writeText(data.apiKey);
setKeyCopied(true);
setTimeout(() => setKeyCopied(false), 2000);
} else {
setRevealedKey(data.apiKey);
}
setShowPasswordPrompt(null);
setVerifyPassword('');
} catch (err) {
setVerifyError(err instanceof Error ? err.message : 'Verification failed');
} finally {
setVerifyLoading(false);
const handleReveal = (apiKey: string) => {
if (passwordPromptMode === 'copy') {
navigator.clipboard.writeText(apiKey);
setKeyCopied(true);
setTimeout(() => setKeyCopied(false), 2000);
} else {
setRevealedKey(apiKey);
}
setPasswordPromptMode(null);
};
const copyFreshKey = () => {
@@ -298,8 +282,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
{/* Reveal button */}
<button
onClick={() => {
if (revealedKey) { setRevealedKey(null); }
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
if (revealedKey) setRevealedKey(null);
else setPasswordPromptMode('reveal');
}}
className="btn-outline shrink-0 px-2.5"
>
@@ -317,9 +301,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
setKeyCopied(true);
setTimeout(() => setKeyCopied(false), 2000);
} else {
setShowPasswordPrompt('copy');
setVerifyPassword('');
setVerifyError('');
setPasswordPromptMode('copy');
}
}}
className="btn-outline shrink-0 px-2.5"
@@ -332,53 +314,20 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</button>
</div>
{showPasswordPrompt && (
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
{hasPassword ? (
<>
<p className="text-[13px] text-text-secondary">
{t('dashboard.settings.passwordPrompt', {
action: showPasswordPrompt === 'copy'
? t('dashboard.settings.passwordPromptCopy')
: t('dashboard.settings.passwordPromptReveal'),
})}
</p>
<input
type="password"
value={verifyPassword}
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
className="input-base"
placeholder={t('dashboard.settings.currentPassword')}
autoFocus
/>
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
<div className="flex gap-2">
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</>
) : (
<>
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
<div className="flex gap-2">
<button
onClick={() => {
setShowPasswordPrompt(null);
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
}}
className="btn-primary text-[13px] py-1.5"
>
{t('dashboard.settings.setPasswordAction')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</>
)}
</div>
)}
<PasswordRevealPrompt
open={passwordPromptMode !== null}
promptText={t('dashboard.settings.passwordPrompt', {
action: passwordPromptMode === 'copy'
? t('dashboard.settings.passwordPromptCopy')
: t('dashboard.settings.passwordPromptReveal'),
})}
onCancel={() => setPasswordPromptMode(null)}
onReveal={handleReveal}
onSetPasswordClick={() => {
setPasswordPromptMode(null);
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
}}
/>
<button
onClick={() => setShowRotateConfirm(true)}

View File

@@ -247,6 +247,7 @@ const en = {
'dashboard.projectDetail.tabMcp': 'MCP',
'dashboard.projectDetail.tabDocs': 'Documentation',
'dashboard.projectDetail.tabModules': 'Modules',
'dashboard.projectDetail.tabUsage': 'Usage',
'dashboard.projectDetail.tabSettings': 'Settings',
// ===== Import Dialog =====
@@ -254,6 +255,8 @@ const en = {
'dashboard.import.desc': 'Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.',
'dashboard.import.successTitle': 'Import Successful',
'dashboard.import.goToProject': 'Go to Project',
'dashboard.import.nextStep': 'Next: configure your LLM client to connect to this project via MCP.',
'dashboard.import.configureClient': 'Configure MCP Client',
// ===== Reimport Dialog =====
'dashboard.reimport.title': 'Re-import API Document',
@@ -271,11 +274,16 @@ const en = {
// ===== MCP Integration =====
'dashboard.mcp.urlTitle': 'MCP Service URL',
'dashboard.mcp.urlDesc': 'Connect your LLM client to this endpoint.',
'dashboard.mcp.configTitle': 'Configuration for Claude Code / Cursor',
'dashboard.mcp.configDesc': 'Add this to your MCP client configuration.',
'dashboard.mcp.keyGenerated': 'API key generated. Copy it from',
'dashboard.mcp.keyReplace': 'and replace',
'dashboard.mcp.keyAbove': 'above.',
'dashboard.mcp.testConnection': 'Test',
'dashboard.mcp.testing': 'Testing…',
'dashboard.mcp.testOk': 'Reachable',
'dashboard.mcp.testFail': 'Failed',
'dashboard.mcp.configTitle': 'MCP Client Configuration',
'dashboard.mcp.configDesc': 'Pick your client and paste the snippet into the indicated location.',
'dashboard.mcp.configLocation': 'Config file',
'dashboard.mcp.configLocationAlt': 'Or',
'dashboard.mcp.copyWithKey': 'Copy with API key',
'dashboard.mcp.revealPrompt': 'Enter your password to embed your real API key in the copied configuration.',
'dashboard.mcp.noKeyWarning': 'You need to generate an API key before using MCP.',
'dashboard.mcp.openSettings': 'Open Settings',
'dashboard.mcp.toolsTitle': 'Available MCP Tools',
@@ -286,6 +294,18 @@ const en = {
'dashboard.mcp.tool4Desc': 'Get full endpoint details: parameters, request body, responses.',
'dashboard.mcp.tool5Desc': 'Search by keyword across all endpoints. Optional moduleId filter.',
// ===== Usage =====
'dashboard.usage.title': 'MCP Usage',
'dashboard.usage.desc': 'Last {days} days of MCP tool calls for this project.',
'dashboard.usage.totalCalls': 'Total Calls',
'dashboard.usage.errors': 'Errors',
'dashboard.usage.successRate': 'Success Rate',
'dashboard.usage.tokens': 'Est. Tokens',
'dashboard.usage.calls': 'calls',
'dashboard.usage.dailyTitle': 'Daily Calls',
'dashboard.usage.byToolTitle': 'By Tool',
'dashboard.usage.empty': 'No calls in this range yet.',
// ===== Project Settings =====
'dashboard.projectSettings.generalTitle': 'General',
'dashboard.projectSettings.generalDesc': 'Update your project name and description.',

View File

@@ -249,6 +249,7 @@ const zh: Record<TranslationKey, string> = {
'dashboard.projectDetail.tabMcp': 'MCP',
'dashboard.projectDetail.tabDocs': '文档',
'dashboard.projectDetail.tabModules': '模块',
'dashboard.projectDetail.tabUsage': '调用统计',
'dashboard.projectDetail.tabSettings': '设置',
// ===== Import Dialog =====
@@ -256,6 +257,8 @@ const zh: Record<TranslationKey, string> = {
'dashboard.import.desc': '导入 Swagger 2.0 或 OpenAPI 3.x 文档以创建新项目。',
'dashboard.import.successTitle': '导入成功',
'dashboard.import.goToProject': '前往项目',
'dashboard.import.nextStep': '下一步:配置你的 LLM 客户端,通过 MCP 连接到此项目。',
'dashboard.import.configureClient': '配置 MCP 客户端',
// ===== Reimport Dialog =====
'dashboard.reimport.title': '重新导入 API 文档',
@@ -273,11 +276,16 @@ const zh: Record<TranslationKey, string> = {
// ===== MCP Integration =====
'dashboard.mcp.urlTitle': 'MCP 服务 URL',
'dashboard.mcp.urlDesc': '将你的 LLM 客户端连接到此端点。',
'dashboard.mcp.configTitle': 'Claude Code / Cursor 配置',
'dashboard.mcp.configDesc': '将此内容添加到你的 MCP 客户端配置中。',
'dashboard.mcp.keyGenerated': 'API Key 已生成。从',
'dashboard.mcp.keyReplace': '复制并替换上方的',
'dashboard.mcp.keyAbove': '。',
'dashboard.mcp.testConnection': '测试连接',
'dashboard.mcp.testing': '测试中…',
'dashboard.mcp.testOk': '连接成功',
'dashboard.mcp.testFail': '连接失败',
'dashboard.mcp.configTitle': 'MCP 客户端配置',
'dashboard.mcp.configDesc': '选择你的客户端,将下方代码粘贴到指定位置。',
'dashboard.mcp.configLocation': '配置文件',
'dashboard.mcp.configLocationAlt': '或',
'dashboard.mcp.copyWithKey': '复制(含 API Key',
'dashboard.mcp.revealPrompt': '输入密码以将真实 API Key 嵌入到复制的配置中。',
'dashboard.mcp.noKeyWarning': '使用 MCP 前需要先生成 API Key。',
'dashboard.mcp.openSettings': '打开设置',
'dashboard.mcp.toolsTitle': '可用 MCP 工具',
@@ -288,6 +296,18 @@ const zh: Record<TranslationKey, string> = {
'dashboard.mcp.tool4Desc': '获取完整端点详情:参数、请求体、响应。',
'dashboard.mcp.tool5Desc': '按关键词搜索所有端点。可选 moduleId 过滤。',
// ===== Usage =====
'dashboard.usage.title': 'MCP 调用统计',
'dashboard.usage.desc': '过去 {days} 天此项目的 MCP 工具调用情况。',
'dashboard.usage.totalCalls': '调用总数',
'dashboard.usage.errors': '错误数',
'dashboard.usage.successRate': '成功率',
'dashboard.usage.tokens': '估算 Token',
'dashboard.usage.calls': '次调用',
'dashboard.usage.dailyTitle': '每日调用',
'dashboard.usage.byToolTitle': '按工具统计',
'dashboard.usage.empty': '此时间范围内暂无调用。',
// ===== Project Settings =====
'dashboard.projectSettings.generalTitle': '基本信息',
'dashboard.projectSettings.generalDesc': '更新项目名称和描述。',

View File

@@ -0,0 +1,96 @@
export type McpClientId =
| 'claude-desktop'
| 'claude-code'
| 'cursor'
| 'cline'
| 'windsurf'
| 'codex'
| 'github-copilot';
export type McpClientMeta = {
id: McpClientId;
label: string;
configPath: string;
configPathAlt?: string;
rootKey: 'mcpServers' | 'servers';
restartHint: string;
};
export const MCP_CLIENTS: McpClientMeta[] = [
{
id: 'claude-desktop',
label: 'Claude Desktop',
configPath: '~/Library/Application Support/Claude/claude_desktop_config.json',
configPathAlt: '%APPDATA%\\Claude\\claude_desktop_config.json',
rootKey: 'mcpServers',
restartHint: 'Fully quit and reopen Claude Desktop after saving.',
},
{
id: 'claude-code',
label: 'Claude Code',
configPath: '<project>/.mcp.json',
configPathAlt: '~/.claude.json (global)',
rootKey: 'mcpServers',
restartHint: 'Restart `claude` in the project directory; verify with `claude mcp list`.',
},
{
id: 'cursor',
label: 'Cursor',
configPath: '<project>/.cursor/mcp.json',
rootKey: 'mcpServers',
restartHint: 'Cursor reloads MCP automatically. Use Agent mode (not Ask) to invoke tools.',
},
{
id: 'cline',
label: 'Cline (VS Code)',
configPath: 'Cline sidebar → MCP Servers → settings (gear icon)',
rootKey: 'mcpServers',
restartHint: 'Cline reconnects automatically after saving.',
},
{
id: 'windsurf',
label: 'Windsurf',
configPath: '~/.codeium/windsurf/mcp_config.json',
rootKey: 'mcpServers',
restartHint: 'Restart Windsurf after saving the config file.',
},
{
id: 'codex',
label: 'Codex (OpenAI)',
configPath: '~/.codex/config.json',
configPathAlt: '<project>/.codex/mcp.json',
rootKey: 'mcpServers',
restartHint: 'New Codex sessions pick up the config automatically.',
},
{
id: 'github-copilot',
label: 'GitHub Copilot (VS Code)',
configPath: '<project>/.vscode/mcp.json',
rootKey: 'servers',
restartHint: 'Verify with command palette → "MCP: List Servers". Note the root key is `servers`, not `mcpServers`.',
},
];
const KEY_PLACEHOLDER = '<your-api-key>';
export function buildClientConfig(
client: McpClientMeta,
serverName: string,
url: string,
apiKey: string | null,
): string {
const config = {
[client.rootKey]: {
[serverName]: {
type: 'http',
url,
headers: { Authorization: `Bearer ${apiKey ?? KEY_PLACEHOLDER}` },
},
},
};
return JSON.stringify(config, null, 2);
}
export function sanitizeServerName(projectName: string): string {
return projectName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'agent-fox';
}

View File

@@ -143,8 +143,14 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
</div>
</div>
<div className="flex justify-end">
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">{t('dashboard.import.goToProject')}</button>
<p className="text-[13px] text-text-secondary">{t('dashboard.import.nextStep')}</p>
<div className="flex justify-end gap-2.5">
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-outline">{t('dashboard.import.goToProject')}</button>
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">
{t('dashboard.import.configureClient')}
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</div>
)}

View File

@@ -7,6 +7,7 @@ import DocPreview from './tabs/DocPreview';
import ModuleManagement from './tabs/ModuleManagement';
import McpIntegration from './tabs/McpIntegration';
import ProjectSettings from './tabs/ProjectSettings';
import Usage from './tabs/Usage';
import Badge from '../components/Badge';
import Skeleton from '../components/Skeleton';
@@ -21,6 +22,7 @@ const tabs = [
{ key: 'mcp', labelKey: 'dashboard.projectDetail.tabMcp', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' },
{ key: 'docs', labelKey: 'dashboard.projectDetail.tabDocs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ key: 'modules', labelKey: 'dashboard.projectDetail.tabModules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ key: 'usage', labelKey: 'dashboard.projectDetail.tabUsage', icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' },
{ key: 'settings', labelKey: 'dashboard.projectDetail.tabSettings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
] as const;
@@ -104,6 +106,7 @@ export default function ProjectDetail() {
{activeTab === 'docs' && <DocPreview projectId={project.id} />}
{activeTab === 'modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'mcp' && <McpIntegration project={project} />}
{activeTab === 'usage' && <Usage projectId={project.id} />}
{activeTab === 'settings' && <ProjectSettings project={{ ...project, _count: project._count }} />}
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import BarChart, { type BarChartPoint } from '../../components/BarChart';
type Stats = {
totalUsers: number;
@@ -137,7 +138,18 @@ export default function Dashboard() {
},
];
const maxCalls = Math.max(...(trends?.map(t => t.calls) ?? [1]), 1);
const trendPoints: BarChartPoint[] = (trends ?? []).map((point) => ({
key: point.date,
label: point.date.slice(5),
value: point.calls,
tooltip: (
<>
<div className="font-medium text-text-primary">{point.calls} </div>
<div className="text-text-muted"> {point.successRate}%</div>
<div className="text-text-muted"> {point.avgDuration}ms</div>
</>
),
}));
return (
<div className="space-y-6 animate-fade-in">
@@ -164,40 +176,7 @@ export default function Dashboard() {
{/* Trend Chart */}
<div className="xl:col-span-3 card p-5">
<h3 className="section-title mb-4">7 </h3>
{trends && trends.length > 0 ? (
<div className="flex items-end gap-1.5 h-[180px]">
{trends.map((point) => {
const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0;
return (
<div key={point.date} className="flex-1 flex flex-col items-center gap-1 group relative">
{/* Tooltip */}
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
<div className="font-medium text-text-primary">{point.calls} </div>
<div className="text-text-muted"> {point.successRate}%</div>
<div className="text-text-muted"> {point.avgDuration}ms</div>
</div>
</div>
{/* Bar */}
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-accent/70 hover:bg-accent transition-colors cursor-default"
style={{
height: `${Math.max(height, 2)}%`,
transition: 'height 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
}}
/>
</div>
<span className="text-[9px] text-text-muted">{point.date.slice(5)}</span>
</div>
);
})}
</div>
) : (
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
</div>
)}
<BarChart points={trendPoints} emptyLabel="暂无调用数据" />
</div>
{/* Recent Calls */}

View File

@@ -1,32 +1,51 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import { useI18n } from '../../lib/i18n';
import { useLayoutContext } from '../Layout';
import PasswordRevealPrompt from '../../components/PasswordRevealPrompt';
import { MCP_CLIENTS, buildClientConfig, sanitizeServerName, type McpClientId } from '../../lib/mcp-clients';
type Project = { id: string; name: string };
type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
type McpInfo = {
projectName: string;
openApiVersion: string;
totalEndpoints: number;
totalModules: number;
toolCount: number;
};
type TestStatus =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ok'; info: McpInfo }
| { kind: 'error'; message: string };
export default function McpIntegration({ project }: { project: Project }) {
const [copied, setCopied] = useState<string | null>(null);
const { onOpenSettings } = useLayoutContext();
const { t } = useI18n();
const { onOpenSettings } = useLayoutContext();
const mcpUrl = `${window.location.origin}/mcp/${project.id}`;
const serverName = useMemo(() => sanitizeServerName(project.name), [project.name]);
const [activeClient, setActiveClient] = useState<McpClientId>('claude-desktop');
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [testStatus, setTestStatus] = useState<TestStatus>({ kind: 'idle' });
const { data: keyStatus } = useQuery({
queryKey: ['api-key-status'],
queryFn: () => apiFetch<{ hasKey: boolean; prefix: string | null }>('/auth/api-key/status'),
queryFn: () => apiFetch<ApiKeyStatus>('/auth/api-key/status'),
});
const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const configSnippet = JSON.stringify({
mcpServers: {
[serverName]: {
type: 'http',
url: mcpUrl,
headers: { Authorization: 'Bearer <your-api-key>' },
},
},
}, null, 2);
useEffect(() => { setTestStatus({ kind: 'idle' }); }, [mcpUrl]);
const client = MCP_CLIENTS.find((c) => c.id === activeClient) ?? MCP_CLIENTS[0];
const configSnippet = buildClientConfig(client, serverName, mcpUrl, revealedKey);
const copyText = (text: string, key: string) => {
navigator.clipboard.writeText(text);
@@ -34,6 +53,38 @@ export default function McpIntegration({ project }: { project: Project }) {
setTimeout(() => setCopied(null), 2000);
};
const requestRevealAndCopy = () => {
if (!keyStatus?.hasKey) return;
if (revealedKey) {
copyText(buildClientConfig(client, serverName, mcpUrl, revealedKey), 'config');
return;
}
setPasswordPromptOpen(true);
};
const handleReveal = (apiKey: string) => {
setRevealedKey(apiKey);
// Copy synchronously off the user gesture chain — re-using `apiKey` instead
// of `revealedKey` because state hasn't propagated yet.
copyText(buildClientConfig(client, serverName, mcpUrl, apiKey), 'config');
setPasswordPromptOpen(false);
};
const testConnection = async () => {
setTestStatus({ kind: 'loading' });
try {
const res = await fetch(`${mcpUrl}/info`);
if (!res.ok) {
setTestStatus({ kind: 'error', message: `HTTP ${res.status}` });
return;
}
const info = (await res.json()) as McpInfo;
setTestStatus({ kind: 'ok', info });
} catch (err) {
setTestStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Network error' });
}
};
return (
<div className="max-w-2xl space-y-8">
{/* MCP URL */}
@@ -43,47 +94,105 @@ export default function McpIntegration({ project }: { project: Project }) {
<div className="flex items-center gap-2">
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
{copied === 'url' ? (
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
) : (
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('common.copy')}</>
)}
{copied === 'url' ? t('common.copied') : t('common.copy')}
</button>
<button onClick={testConnection} disabled={testStatus.kind === 'loading'} className="btn-outline shrink-0">
{testStatus.kind === 'loading' ? t('dashboard.mcp.testing') : t('dashboard.mcp.testConnection')}
</button>
</div>
{testStatus.kind === 'ok' && (
<div className="mt-2.5 p-3 rounded-lg bg-success-muted text-[12px] text-text-secondary flex items-start gap-2">
<svg className="w-4 h-4 text-success shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
<div>
<div className="font-medium text-success">{t('dashboard.mcp.testOk')}</div>
<div className="text-text-muted mt-0.5">
{testStatus.info.projectName} · OpenAPI {testStatus.info.openApiVersion} · {testStatus.info.totalModules} {t('common.modules')} · {testStatus.info.totalEndpoints} {t('common.endpoints')} · {testStatus.info.toolCount} tools
</div>
</div>
</div>
)}
{testStatus.kind === 'error' && (
<div className="mt-2.5 p-3 rounded-lg bg-danger-muted text-[12px] text-danger flex items-center gap-2">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
<span>{t('dashboard.mcp.testFail')}: {testStatus.message}</span>
</div>
)}
</section>
{/* Config snippet */}
{/* Client selector + Config */}
<section>
<p className="section-title">{t('dashboard.mcp.configTitle')}</p>
<p className="section-desc mb-3">{t('dashboard.mcp.configDesc')}</p>
<div className="relative">
<pre className="code-block text-xs">{configSnippet}</pre>
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
</button>
{/* Client tabs */}
<div className="flex flex-wrap gap-1 p-1 rounded-lg bg-bg-tertiary border border-border-muted mb-3">
{MCP_CLIENTS.map((c) => (
<button
key={c.id}
onClick={() => setActiveClient(c.id)}
className={`px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
activeClient === c.id
? 'bg-bg-elevated text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{c.label}
</button>
))}
</div>
{/* API Key guidance */}
{keyStatus && (
<div className="mt-3">
{keyStatus.hasKey ? (
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
<svg className="w-4 h-4 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
<p className="text-[13px] text-text-secondary">
{t('dashboard.mcp.keyGenerated')}{' '}
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">{t('common.settings')}</button>
{' '}{t('dashboard.mcp.keyReplace')} <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded">&lt;your-api-key&gt;</code> {t('dashboard.mcp.keyAbove')}
</p>
</div>
) : (
<div className="flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
{t('dashboard.mcp.openSettings')}
</button>
</div>
{/* Setup hint */}
<div className="mb-3 p-3 rounded-lg bg-bg-tertiary border border-border-muted text-[12px] text-text-secondary space-y-1">
<div>
<span className="text-text-muted">{t('dashboard.mcp.configLocation')}</span>
<code className="font-mono text-text-primary">{client.configPath}</code>
</div>
{client.configPathAlt && (
<div>
<span className="text-text-muted">{t('dashboard.mcp.configLocationAlt')}</span>
<code className="font-mono text-text-primary">{client.configPathAlt}</code>
</div>
)}
<div className="text-text-muted pt-1">{client.restartHint}</div>
</div>
{/* Config snippet */}
<div className="relative">
<pre className="code-block text-xs">{configSnippet}</pre>
<div className="absolute top-2.5 right-2.5 flex gap-1.5">
{keyStatus?.hasKey && !revealedKey && (
<button
onClick={requestRevealAndCopy}
className="copy-btn"
title={t('dashboard.mcp.copyWithKey')}
>
{t('dashboard.mcp.copyWithKey')}
</button>
)}
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn">
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
</button>
</div>
</div>
<div className="mt-3">
<PasswordRevealPrompt
open={passwordPromptOpen}
promptText={t('dashboard.mcp.revealPrompt')}
onCancel={() => setPasswordPromptOpen(false)}
onReveal={handleReveal}
onSetPasswordClick={() => { setPasswordPromptOpen(false); onOpenSettings(); }}
/>
</div>
{/* API Key status */}
{keyStatus && !keyStatus.hasKey && (
<div className="mt-3 flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
{t('dashboard.mcp.openSettings')}
</button>
</div>
)}
</section>

View File

@@ -0,0 +1,125 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import { useI18n } from '../../lib/i18n';
import BarChart, { type BarChartPoint } from '../../components/BarChart';
type DailyPoint = { date: string; calls: number; errors: number; tokens: number };
type ToolStat = { toolName: string; calls: number; tokens: number };
type UsageData = {
days: number;
totals: { calls: number; errors: number; tokens: number };
daily: DailyPoint[];
byTool: ToolStat[];
};
export default function Usage({ projectId }: { projectId: string }) {
const { t } = useI18n();
const [days, setDays] = useState<7 | 30>(7);
const { data, isLoading } = useQuery({
queryKey: ['project-usage', projectId, days],
queryFn: () => apiFetch<UsageData>(`/projects/${projectId}/usage?days=${days}`),
});
const maxToolCalls = Math.max(...(data?.byTool.map((b) => b.calls) ?? [1]), 1);
const dailyPoints: BarChartPoint[] = (data?.daily ?? []).map((point) => ({
key: point.date,
label: point.date.slice(5),
value: point.calls,
secondary: point.errors,
tooltip: (
<>
<div className="font-medium text-text-primary">{point.calls} {t('dashboard.usage.calls')}</div>
{point.errors > 0 && <div className="text-danger">{point.errors} {t('dashboard.usage.errors')}</div>}
<div className="text-text-muted">{point.tokens.toLocaleString()} {t('dashboard.usage.tokens')}</div>
</>
),
}));
const successRate = data && data.totals.calls > 0
? `${Math.round(((data.totals.calls - data.totals.errors) / data.totals.calls) * 100)}%`
: '—';
return (
<div className="space-y-6 animate-fade-in">
{/* Range selector */}
<div className="flex items-center justify-between">
<div>
<p className="section-title">{t('dashboard.usage.title')}</p>
<p className="section-desc">{t('dashboard.usage.desc', { days: String(days) })}</p>
</div>
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary border border-border-muted">
{([7, 30] as const).map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
days === d ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'
}`}
>
{d}d
</button>
))}
</div>
</div>
{/* Totals */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label={t('dashboard.usage.totalCalls')} value={data?.totals.calls ?? 0} loading={isLoading} />
<StatCard label={t('dashboard.usage.errors')} value={data?.totals.errors ?? 0} loading={isLoading} tone={data && data.totals.errors > 0 ? 'danger' : 'default'} />
<StatCard label={t('dashboard.usage.successRate')} value={successRate} loading={isLoading} />
<StatCard label={t('dashboard.usage.tokens')} value={(data?.totals.tokens ?? 0).toLocaleString()} loading={isLoading} />
</div>
{/* Daily chart */}
<div className="card p-5">
<h3 className="section-title mb-4">{t('dashboard.usage.dailyTitle')}</h3>
{isLoading ? (
<div className="h-[180px] skeleton" />
) : (
<BarChart points={dailyPoints} emptyLabel={t('dashboard.usage.empty')} />
)}
</div>
{/* By tool */}
<div className="card p-5">
<h3 className="section-title mb-4">{t('dashboard.usage.byToolTitle')}</h3>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => <div key={i} className="skeleton h-6" />)}
</div>
) : data && data.byTool.length > 0 ? (
<div className="space-y-2.5">
{data.byTool.map((tool) => {
const width = (tool.calls / maxToolCalls) * 100;
return (
<div key={tool.toolName} className="flex items-center gap-3 text-[12px]">
<code className="font-mono text-accent w-44 shrink-0 truncate">{tool.toolName}</code>
<div className="flex-1 h-5 bg-bg-tertiary rounded-md overflow-hidden">
<div className="h-full bg-accent/70 rounded-md transition-all" style={{ width: `${width}%` }} />
</div>
<span className="text-text-muted tabular-nums w-16 text-right">{tool.calls.toLocaleString()}</span>
<span className="text-text-muted tabular-nums w-20 text-right">{tool.tokens.toLocaleString()}t</span>
</div>
);
})}
</div>
) : (
<p className="text-[13px] text-text-muted">{t('dashboard.usage.empty')}</p>
)}
</div>
</div>
);
}
function StatCard({ label, value, loading, tone }: { label: string; value: string | number; loading: boolean; tone?: 'default' | 'danger' }) {
return (
<div className="card p-4">
<div className={`text-2xl font-bold tabular-nums ${tone === 'danger' ? 'text-danger' : 'text-text-primary'}`}>
{loading ? <span className="skeleton inline-block w-16 h-7 align-middle" /> : value}
</div>
<div className="text-[12px] text-text-muted mt-1">{label}</div>
</div>
);
}