69a0f7b24c
- 侧边栏导航:仪表盘、登录、内容浏览、发布、互动、API 测试、设置 - 7 个页面所有按钮、标签、提示、错误信息改为中文 - API 端点列表分类改为中文(登录、内容、发布、互动) - 组件内文本:展开/收起、复制、点赞、收藏、评论等 - 页面标题改为 Social MCP - 管理后台
490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
||
import { Card } from '@/components/ui/Card';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Select } from '@/components/ui/Select';
|
||
import { Badge } from '@/components/ui/Badge';
|
||
import { Tabs } from '@/components/ui/Tabs';
|
||
import { JsonViewer } from '@/components/ui/JsonViewer';
|
||
import { Spinner } from '@/components/ui/Spinner';
|
||
import { API_ENDPOINTS } from '@/lib/constants';
|
||
import { apiFetch, generateCurl } from '@/api/client';
|
||
import {
|
||
connect,
|
||
disconnect,
|
||
listTools,
|
||
callTool,
|
||
getMcpConnectionState,
|
||
onConnectionStateChange,
|
||
type McpToolInfo,
|
||
type McpConnectionState,
|
||
} from '@/api/mcp-client';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface HistoryEntry {
|
||
id: number;
|
||
mode: 'REST' | 'MCP';
|
||
label: string;
|
||
status: 'success' | 'error';
|
||
time: string;
|
||
duration: number;
|
||
response: unknown;
|
||
}
|
||
|
||
let historyId = 0;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function ApiTesterPage() {
|
||
const [mode, setMode] = useState<'rest' | 'mcp'>('rest');
|
||
const [response, setResponse] = useState<unknown>(null);
|
||
const [responseStatus, setResponseStatus] = useState<'success' | 'error' | null>(null);
|
||
const [duration, setDuration] = useState<number | null>(null);
|
||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||
|
||
const addHistory = useCallback((entry: Omit<HistoryEntry, 'id'>) => {
|
||
setHistory((prev) => [{ ...entry, id: historyId++ }, ...prev].slice(0, 30));
|
||
}, []);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h1 className="text-2xl font-bold">API 测试</h1>
|
||
|
||
<Tabs
|
||
tabs={[
|
||
{ key: 'rest', label: 'REST API' },
|
||
{ key: 'mcp', label: 'MCP 工具' },
|
||
]}
|
||
active={mode}
|
||
onChange={(k) => setMode(k as 'rest' | 'mcp')}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{mode === 'rest' ? (
|
||
<RestPanel
|
||
response={response}
|
||
setResponse={setResponse}
|
||
setResponseStatus={setResponseStatus}
|
||
setDuration={setDuration}
|
||
addHistory={addHistory}
|
||
/>
|
||
) : (
|
||
<McpPanel
|
||
response={response}
|
||
setResponse={setResponse}
|
||
setResponseStatus={setResponseStatus}
|
||
setDuration={setDuration}
|
||
addHistory={addHistory}
|
||
/>
|
||
)}
|
||
|
||
{/* Response */}
|
||
{response !== null && (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">响应</h2>
|
||
<div className="flex items-center gap-2">
|
||
{responseStatus && (
|
||
<Badge variant={responseStatus === 'success' ? 'success' : 'danger'}>
|
||
{responseStatus === 'success' ? '成功' : '失败'}
|
||
</Badge>
|
||
)}
|
||
{duration !== null && (
|
||
<span className="text-xs text-dark-muted">{duration}ms</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<JsonViewer data={response} maxHeight="500px" />
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{/* History */}
|
||
<div>
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">历史记录</h2>
|
||
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>清空</Button>
|
||
</div>
|
||
{history.length === 0 ? (
|
||
<p className="text-sm text-dark-muted">暂无请求</p>
|
||
) : (
|
||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||
{history.map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="p-2 rounded-lg border border-dark-border/50 hover:bg-dark-hover cursor-pointer text-xs"
|
||
onClick={() => {
|
||
setResponse(entry.response);
|
||
setResponseStatus(entry.status);
|
||
setDuration(entry.duration);
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Badge
|
||
variant={entry.mode === 'MCP' ? 'info' : 'success'}
|
||
className="text-[10px]"
|
||
>
|
||
{entry.mode}
|
||
</Badge>
|
||
<span className="font-mono truncate">{entry.label}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-1 text-dark-muted">
|
||
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
|
||
{entry.status === 'success' ? '成功' : '失败'}
|
||
</Badge>
|
||
<span>{entry.time}</span>
|
||
<span>{entry.duration}ms</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// REST Panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function RestPanel({
|
||
setResponse,
|
||
setResponseStatus,
|
||
setDuration,
|
||
addHistory,
|
||
}: {
|
||
response: unknown;
|
||
setResponse: (v: unknown) => void;
|
||
setResponseStatus: (v: 'success' | 'error' | null) => void;
|
||
setDuration: (v: number | null) => void;
|
||
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
|
||
}) {
|
||
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
|
||
const [bodyText, setBodyText] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
|
||
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
|
||
|
||
const handleEndpointChange = useCallback((key: string) => {
|
||
setSelectedKey(key);
|
||
setResponse(null);
|
||
setResponseStatus(null);
|
||
setDuration(null);
|
||
const ep = API_ENDPOINTS.find((e) => e.key === key);
|
||
if (ep && 'body' in ep && ep.body) {
|
||
setBodyText(JSON.stringify(ep.body, null, 2));
|
||
} else {
|
||
setBodyText('');
|
||
}
|
||
}, [setResponse, setResponseStatus, setDuration]);
|
||
|
||
const handleSend = useCallback(async () => {
|
||
setLoading(true);
|
||
setResponse(null);
|
||
const start = Date.now();
|
||
try {
|
||
let body: unknown = undefined;
|
||
if (bodyText.trim() && endpoint.method !== 'GET') {
|
||
body = JSON.parse(bodyText);
|
||
}
|
||
const res = await apiFetch<unknown>(endpoint.path, {
|
||
method: endpoint.method,
|
||
...(body ? { body: JSON.stringify(body) } : {}),
|
||
});
|
||
const dur = Date.now() - start;
|
||
setResponse(res);
|
||
setResponseStatus('success');
|
||
setDuration(dur);
|
||
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
|
||
} catch (err) {
|
||
const dur = Date.now() - start;
|
||
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
||
setResponse(errData);
|
||
setResponseStatus('error');
|
||
setDuration(dur);
|
||
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [endpoint, bodyText, setResponse, setResponseStatus, setDuration, addHistory]);
|
||
|
||
const curl = generateCurl(
|
||
endpoint.method,
|
||
endpoint.path,
|
||
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText) as unknown; } catch { return undefined; } })() : undefined,
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<Card>
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">REST 请求</h2>
|
||
<div className="space-y-3">
|
||
<Select
|
||
label="端点"
|
||
options={categories.flatMap((cat) => [
|
||
{ value: `__cat_${cat}`, label: `── ${cat} ──` },
|
||
...API_ENDPOINTS.filter((e) => e.category === cat).map((e) => ({
|
||
value: e.key,
|
||
label: `${e.method} ${e.path}`,
|
||
})),
|
||
])}
|
||
value={selectedKey}
|
||
onChange={(e) => {
|
||
if (!e.target.value.startsWith('__cat_')) {
|
||
handleEndpointChange(e.target.value);
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant={endpoint.method === 'GET' ? 'success' : endpoint.method === 'DELETE' ? 'danger' : 'info'}>
|
||
{endpoint.method}
|
||
</Badge>
|
||
<code className="text-sm text-dark-text font-mono">{endpoint.path}</code>
|
||
</div>
|
||
{endpoint.method !== 'GET' && (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm text-dark-muted">请求体 (JSON)</label>
|
||
<textarea
|
||
value={bodyText}
|
||
onChange={(e) => setBodyText(e.target.value)}
|
||
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<Button onClick={() => void handleSend()} loading={loading}>发送请求</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => void navigator.clipboard.writeText(curl)}>复制 cURL</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card>
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">cURL</h2>
|
||
<pre className="bg-dark-bg border border-dark-border rounded-lg p-3 text-xs font-mono text-dark-text overflow-x-auto whitespace-pre-wrap">{curl}</pre>
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MCP Panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function McpPanel({
|
||
setResponse,
|
||
setResponseStatus,
|
||
setDuration,
|
||
addHistory,
|
||
}: {
|
||
response: unknown;
|
||
setResponse: (v: unknown) => void;
|
||
setResponseStatus: (v: 'success' | 'error' | null) => void;
|
||
setDuration: (v: number | null) => void;
|
||
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
|
||
}) {
|
||
const [connState, setConnState] = useState<McpConnectionState>(getMcpConnectionState());
|
||
const [connecting, setConnecting] = useState(false);
|
||
const [tools, setTools] = useState<McpToolInfo[]>([]);
|
||
const [selectedTool, setSelectedTool] = useState('');
|
||
const [argsText, setArgsText] = useState('{}');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
onConnectionStateChange(setConnState);
|
||
return () => onConnectionStateChange(() => {});
|
||
}, []);
|
||
|
||
const handleConnect = useCallback(async () => {
|
||
setConnecting(true);
|
||
try {
|
||
await connect();
|
||
const result = await listTools();
|
||
setTools(result.tools);
|
||
if (result.tools.length > 0 && !selectedTool) {
|
||
setSelectedTool(result.tools[0]!.name);
|
||
// Generate default args from schema
|
||
const schema = result.tools[0]!.inputSchema;
|
||
setArgsText(generateDefaultArgs(schema));
|
||
}
|
||
} catch (err) {
|
||
setResponse({ error: err instanceof Error ? err.message : String(err) });
|
||
setResponseStatus('error');
|
||
} finally {
|
||
setConnecting(false);
|
||
}
|
||
}, [selectedTool, setResponse, setResponseStatus]);
|
||
|
||
const handleDisconnect = useCallback(() => {
|
||
disconnect();
|
||
setTools([]);
|
||
setSelectedTool('');
|
||
}, []);
|
||
|
||
const handleToolChange = useCallback((name: string) => {
|
||
setSelectedTool(name);
|
||
setResponse(null);
|
||
setResponseStatus(null);
|
||
setDuration(null);
|
||
const tool = tools.find((t) => t.name === name);
|
||
if (tool) {
|
||
setArgsText(generateDefaultArgs(tool.inputSchema));
|
||
}
|
||
}, [tools, setResponse, setResponseStatus, setDuration]);
|
||
|
||
const handleCall = useCallback(async () => {
|
||
if (!selectedTool) return;
|
||
setLoading(true);
|
||
setResponse(null);
|
||
const start = Date.now();
|
||
try {
|
||
let args: Record<string, unknown> = {};
|
||
if (argsText.trim()) {
|
||
args = JSON.parse(argsText) as Record<string, unknown>;
|
||
}
|
||
const res = await callTool(selectedTool, args);
|
||
const dur = Date.now() - start;
|
||
setResponse(res);
|
||
setResponseStatus('success');
|
||
setDuration(dur);
|
||
addHistory({ mode: 'MCP', label: selectedTool, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
|
||
} catch (err) {
|
||
const dur = Date.now() - start;
|
||
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
||
setResponse(errData);
|
||
setResponseStatus('error');
|
||
setDuration(dur);
|
||
addHistory({ mode: 'MCP', label: selectedTool, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedTool, argsText, setResponse, setResponseStatus, setDuration, addHistory]);
|
||
|
||
const currentTool = tools.find((t) => t.name === selectedTool);
|
||
|
||
return (
|
||
<>
|
||
{/* Connection */}
|
||
<Card>
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP 连接</h2>
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={connState === 'connected' ? 'success' : connState === 'connecting' ? 'warning' : 'default'}>
|
||
{connState === 'connected' ? '已连接' : connState === 'connecting' ? '连接中' : '未连接'}
|
||
</Badge>
|
||
{connState === 'disconnected' && (
|
||
<Button size="sm" onClick={() => void handleConnect()} loading={connecting}>
|
||
连接
|
||
</Button>
|
||
)}
|
||
{connState === 'connected' && (
|
||
<>
|
||
<span className="text-xs text-dark-muted">{tools.length} 个工具可用</span>
|
||
<Button size="sm" variant="ghost" onClick={handleDisconnect}>断开</Button>
|
||
</>
|
||
)}
|
||
{connState === 'connecting' && <Spinner size="sm" />}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Tool Call */}
|
||
{connState === 'connected' && tools.length > 0 && (
|
||
<Card>
|
||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP 工具调用</h2>
|
||
<div className="space-y-3">
|
||
<Select
|
||
label="工具"
|
||
options={tools.map((t) => ({ value: t.name, label: t.name }))}
|
||
value={selectedTool}
|
||
onChange={(e) => handleToolChange(e.target.value)}
|
||
/>
|
||
|
||
{/* Tool info */}
|
||
{currentTool && (
|
||
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-2">
|
||
{currentTool.description && (
|
||
<p className="text-dark-muted">{currentTool.description}</p>
|
||
)}
|
||
{currentTool.inputSchema?.properties && (
|
||
<div>
|
||
<p className="text-dark-muted font-semibold mb-1">参数:</p>
|
||
{Object.entries(currentTool.inputSchema.properties).map(([key, prop]) => (
|
||
<div key={key} className="flex gap-2 ml-2">
|
||
<code className="text-dark-accent">{key}</code>
|
||
<span className="text-dark-muted">
|
||
{prop.type || ''}
|
||
{currentTool.inputSchema?.required?.includes(key) ? ' (必填)' : ' (可选)'}
|
||
{prop.description ? ` — ${prop.description}` : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Arguments editor */}
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm text-dark-muted">参数 (JSON)</label>
|
||
<textarea
|
||
value={argsText}
|
||
onChange={(e) => setArgsText(e.target.value)}
|
||
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
|
||
<Button onClick={() => void handleCall()} loading={loading}>
|
||
调用工具
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{connState === 'disconnected' && (
|
||
<Card>
|
||
<div className="text-center py-8 text-dark-muted">
|
||
<p>连接到 MCP 服务器以发现和测试工具。</p>
|
||
<p className="text-xs mt-2">使用与 AI 客户端相同的 SSE + JSON-RPC 协议。</p>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function generateDefaultArgs(schema?: McpToolInfo['inputSchema']): string {
|
||
if (!schema?.properties) return '{}';
|
||
const obj: Record<string, unknown> = {};
|
||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||
if (prop.default !== undefined) {
|
||
obj[key] = prop.default;
|
||
} else if (prop.enum && prop.enum.length > 0) {
|
||
obj[key] = prop.enum[0];
|
||
} else if (prop.type === 'string') {
|
||
obj[key] = '';
|
||
} else if (prop.type === 'boolean') {
|
||
obj[key] = false;
|
||
} else if (prop.type === 'number' || prop.type === 'integer') {
|
||
obj[key] = 0;
|
||
} else if (prop.type === 'array') {
|
||
obj[key] = [];
|
||
} else {
|
||
obj[key] = null;
|
||
}
|
||
}
|
||
return JSON.stringify(obj, null, 2);
|
||
}
|