Files
social-mcp/web/src/pages/ApiTesterPage.tsx
T
kurihada 69a0f7b24c feat: 界面全部改为中文
- 侧边栏导航:仪表盘、登录、内容浏览、发布、互动、API 测试、设置
- 7 个页面所有按钮、标签、提示、错误信息改为中文
- API 端点列表分类改为中文(登录、内容、发布、互动)
- 组件内文本:展开/收起、复制、点赞、收藏、评论等
- 页面标题改为 Social MCP - 管理后台
2026-03-01 16:52:57 +08:00

490 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}