feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面
- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作 - Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出 - Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树 - Publish: 图文/视频发布表单,支持标签、可见性、定时发布 - Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志 - API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录 - Settings: Token 配置、服务器 URL 设置 后端改动: - app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback - Dockerfile: 添加 web 构建阶段 - package.json: 添加 build:web、build:all、dev:web 脚本 技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题) 产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { useState, useCallback } 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 { JsonViewer } from '@/components/ui/JsonViewer';
|
||||
import { API_ENDPOINTS } from '@/lib/constants';
|
||||
import { apiFetch, generateCurl } from '@/api/client';
|
||||
|
||||
interface HistoryEntry {
|
||||
id: number;
|
||||
method: string;
|
||||
path: string;
|
||||
status: 'success' | 'error';
|
||||
time: string;
|
||||
duration: number;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
let historyId = 0;
|
||||
|
||||
export function ApiTesterPage() {
|
||||
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
|
||||
const [bodyText, setBodyText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [response, setResponse] = useState<unknown>(null);
|
||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
|
||||
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
|
||||
|
||||
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('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
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(200);
|
||||
setDuration(dur);
|
||||
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res };
|
||||
setHistory((prev) => [entry, ...prev].slice(0, 20));
|
||||
} catch (err) {
|
||||
const dur = Date.now() - start;
|
||||
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
||||
setResponse(errData);
|
||||
setResponseStatus((err as { status?: number }).status || 500);
|
||||
setDuration(dur);
|
||||
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData };
|
||||
setHistory((prev) => [entry, ...prev].slice(0, 20));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, bodyText]);
|
||||
|
||||
const curl = generateCurl(
|
||||
endpoint.method,
|
||||
endpoint.path,
|
||||
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText); } catch { return undefined; } })() : undefined,
|
||||
);
|
||||
|
||||
// Group endpoints by category
|
||||
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">API Tester</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Left: Request */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Request</h2>
|
||||
|
||||
{/* Endpoint selector */}
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
label="Endpoint"
|
||||
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>
|
||||
|
||||
{/* Body editor */}
|
||||
{endpoint.method !== 'GET' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm text-dark-muted">Request Body (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}>
|
||||
Send Request
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigator.clipboard.writeText(curl)}
|
||||
>
|
||||
Copy cURL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* cURL preview */}
|
||||
<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>
|
||||
|
||||
{/* 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">Response</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{responseStatus && (
|
||||
<Badge variant={responseStatus < 300 ? 'success' : 'danger'}>
|
||||
{responseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{duration !== null && (
|
||||
<span className="text-xs text-dark-muted">{duration}ms</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<JsonViewer data={response} maxHeight="500px" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: History */}
|
||||
<div>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">History</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>Clear</Button>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-dark-muted">No requests yet</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 === 'success' ? 200 : 500);
|
||||
setDuration(entry.duration);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<span className="font-mono truncate">{entry.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-dark-muted">
|
||||
<span>{entry.time}</span>
|
||||
<span>{entry.duration}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user