feat: Phase 3.4 Developer API Management - API Keys, OpenAPI Clients, Call Stats, Slow Logs

This commit is contained in:
qyyfw committed 2026-07-03 19:04:49 +08:00
1 parent 1d32385f17
commit e7d1c39c18
8 files changed
+1359 -4

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
.admin-developer { padding: 20px; max-width: 1400px; margin: 0 auto; }
.dev-header { margin-bottom: 24px; }
.dev-header h2 { font-size: 22px; margin: 0 0 6px 0; }
.dev-header p { color: var(--text-secondary, #666); font-size: 14px; margin: 0; }
/* Stats Grid */
.dev-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
.dev-stat-card {
display: flex; align-items: center; gap: 14px;
padding: 18px; border-radius: 10px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e8e8e8);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.stat-icon { font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 10px; flex-shrink: 0; }
.icon-key { background: rgba(24,144,255,0.1); }
.icon-client { background: rgba(82,196,26,0.1); }
.icon-call { background: rgba(250,173,20,0.1); }
.icon-error { background: rgba(245,34,45,0.1); }
.icon-time { background: rgba(114,46,209,0.1); }
.stat-info { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary, #666); margin-top: 2px; }
.stat-sub { font-size: 11px; color: var(--text-tertiary, #999); margin-top: 2px; }
/* Tabs */
.dev-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border-color, #e8e8e8); margin-bottom: 20px; }
.dev-tab {
padding: 10px 20px; border: none; background: none; cursor: pointer;
font-size: 14px; color: var(--text-secondary, #666);
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
}
.dev-tab.active { color: var(--primary, #1890ff); border-bottom-color: var(--primary, #1890ff); font-weight: 600; }
.dev-tab:hover { color: var(--primary, #1890ff); }
/* Toolbar */
.dev-toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.dev-input {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; flex: 1; min-width: 200px; max-width: 400px;
background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
.dev-input:focus { outline: none; border-color: var(--primary, #1890ff); box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
.dev-select {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
/* Buttons */
.dev-btn {
padding: 8px 16px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
transition: all 0.2s; white-space: nowrap;
}
.dev-btn:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn.primary { background: var(--primary, #1890ff); color: #fff; border-color: var(--primary, #1890ff); }
.dev-btn.primary:hover { background: var(--primary-dark, #096dd9); color: #fff; }
.dev-btn.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
.dev-btn-sm {
padding: 4px 10px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
font-size: 12px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
margin-right: 4px; transition: all 0.2s;
}
.dev-btn-sm:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn-sm.warn { color: var(--warning, #faad14); border-color: var(--warning, #faad14); }
.dev-btn-sm.warn:hover { background: var(--warning, #faad14); color: #fff; }
.dev-btn-sm.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn-sm.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
/* Table */
.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.dev-table th {
text-align: left; padding: 10px 12px; background: var(--table-header-bg, #fafafa);
border-bottom: 2px solid var(--border-color, #e8e8e8); font-weight: 600; white-space: nowrap;
color: var(--text-primary, #333);
}
.dev-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f0f0); }
.dev-table tbody tr:hover { background: var(--table-hover-bg, #f5f5f5); }
.mono-text { font-family: 'Courier New', monospace; font-size: 12px; }
.dev-actions { white-space: nowrap; }
.dev-empty { text-align: center; color: var(--text-tertiary, #999); padding: 40px 12px !important; }
.dev-loading { text-align: center; padding: 40px; color: var(--text-secondary, #666); }
/* Badges */
.dev-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500;
}
.badge-active { background: rgba(82,196,26,0.1); color: #52c41a; }
.badge-revoked { background: rgba(245,34,45,0.1); color: #f5222d; }
.badge-disabled { background: rgba(250,173,20,0.1); color: #faad14; }
/* Method badges */
.method-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
.method-get { background: rgba(82,196,26,0.1); color: #52c41a; }
.method-post { background: rgba(24,144,255,0.1); color: #1890ff; }
.method-put { background: rgba(250,173,20,0.1); color: #faad14; }
.method-delete { background: rgba(245,34,45,0.1); color: #f5222d; }
.method-patch { background: rgba(114,46,209,0.1); color: #722ed1; }
/* Text colors */
.text-danger { color: var(--danger, #f5222d); }
.text-warn { color: var(--warning, #faad14); }
/* Pagination */
.dev-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; }
.dev-pagination button {
padding: 6px 14px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333); font-size: 13px;
}
.dev-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.dev-pagination span { font-size: 13px; color: var(--text-secondary, #666); }
/* Modal */
.dev-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.dev-modal {
background: var(--modal-bg, #fff); border-radius: 10px; padding: 24px;
width: 460px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
}
.dev-modal h3 { margin: 0 0 20px 0; font-size: 18px; }
.dev-form-group { margin-bottom: 16px; }
.dev-form-group label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--text-secondary, #666); }
.dev-form-group .dev-input { width: 100%; max-width: none; box-sizing: border-box; }
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
@media (max-width: 1200px) {
.dev-stats-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
.dev-table { font-size: 12px; }
}
+515
View File
@@ -0,0 +1,515 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Stats {
api_keys: { total: number; active: number; revoked: number };
openapi_clients: { total: number; active: number };
call_stats: { total_calls: number; today_calls: number; total_errors: number; error_rate: number; avg_response_time: number };
slow_logs: { total: number; today: number };
top_endpoints: { endpoint: string; method: string; call_count: number; error_count: number }[];
server_time: string;
}
interface ApiKey {
id: string; user_id: string; username: string | null; api_key: string; name: string;
description: string; rate_limit: number; total_calls: number; status: string;
last_used_at: string | null; created_at: string; expired_at: string | null;
}
interface OpenApiClient {
id: string; user_id: string; username: string | null; name: string; client_id: string;
allowed_models: string[]; rate_limit: number; total_calls: number;
last_used_at: string | null; status: string; created_at: string;
}
interface CallStat {
id: string; endpoint: string; method: string; call_count: number; error_count: number;
error_rate: number; avg_response_time: number; total_response_time: number;
last_called_at: string | null; created_at: string; updated_at: string;
}
interface SlowLog {
id: string; request_id: string | null; method: string; endpoint: string;
status_code: number; response_time: number; threshold: number; created_at: string;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloper() {
const [activeTab, setActiveTab] = useState<'keys' | 'clients' | 'stats' | 'logs'>('keys');
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
// API Keys state
const [keys, setKeys] = useState<PageData<ApiKey>>({ total: 0, page: 1, page_size: 20, items: [] });
const [keySearch, setKeySearch] = useState('');
const [keyStatusFilter, setKeyStatusFilter] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
const [keyForm, setKeyForm] = useState({ user_id: '', name: '', description: '', rate_limit: 60 });
// OpenAPI Clients state
const [clients, setClients] = useState<PageData<OpenApiClient>>({ total: 0, page: 1, page_size: 20, items: [] });
const [clientSearch, setClientSearch] = useState('');
const [showClientModal, setShowClientModal] = useState(false);
const [clientForm, setClientForm] = useState({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
// Call Stats state
const [callStats, setCallStats] = useState<PageData<CallStat>>({ total: 0, page: 1, page_size: 20, items: [] });
const [statSearch, setStatSearch] = useState('');
const [statSortBy, setStatSortBy] = useState('call_count');
// Slow Logs state
const [slowLogs, setSlowLogs] = useState<PageData<SlowLog>>({ total: 0, page: 1, page_size: 20, items: [] });
const [logSearch, setLogSearch] = useState('');
const loadStats = useCallback(async () => {
try {
const res = await api('/admin/developer/stats/overview');
if (res.code === 0) setStats(res.data);
} catch (e) { console.error('Stats error:', e); }
}, []);
const loadKeys = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (keyStatusFilter) params.set('status', keyStatusFilter);
if (keySearch) params.set('keyword', keySearch);
const res = await api(`/admin/developer/api-keys?${params}`);
if (res.code === 0) setKeys(res.data);
} catch (e) { console.error('Keys error:', e); }
setLoading(false);
}, [keySearch, keyStatusFilter]);
const loadClients = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (clientSearch) params.set('keyword', clientSearch);
const res = await api(`/admin/developer/openapi-clients?${params}`);
if (res.code === 0) setClients(res.data);
} catch (e) { console.error('Clients error:', e); }
setLoading(false);
}, [clientSearch]);
const loadCallStats = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20', sort_by: statSortBy });
if (statSearch) params.set('keyword', statSearch);
const res = await api(`/admin/developer/call-stats?${params}`);
if (res.code === 0) setCallStats(res.data);
} catch (e) { console.error('Stats error:', e); }
setLoading(false);
}, [statSearch, statSortBy]);
const loadSlowLogs = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (logSearch) params.set('keyword', logSearch);
const res = await api(`/admin/developer/slow-logs?${params}`);
if (res.code === 0) setSlowLogs(res.data);
} catch (e) { console.error('Logs error:', e); }
setLoading(false);
}, [logSearch]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { if (activeTab === 'keys') loadKeys(); }, [activeTab, loadKeys]);
useEffect(() => { if (activeTab === 'clients') loadClients(); }, [activeTab, loadClients]);
useEffect(() => { if (activeTab === 'stats') loadCallStats(); }, [activeTab, loadCallStats]);
useEffect(() => { if (activeTab === 'logs') loadSlowLogs(); }, [activeTab, loadSlowLogs]);
const handleCreateKey = async () => {
try {
const body: any = { user_id: keyForm.user_id, name: keyForm.name, description: keyForm.description, rate_limit: keyForm.rate_limit };
await api('/admin/developer/api-keys/', { method: 'POST', body: JSON.stringify(body) });
setShowKeyModal(false);
setKeyForm({ user_id: '', name: '', description: '', rate_limit: 60 });
loadKeys();
loadStats();
} catch (e: any) { alert(e.message); }
};
const handleRevokeKey = async (id: string) => {
if (!confirm('Revoke this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}/revoke`, { method: 'POST' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleDeleteKey = async (id: string) => {
if (!confirm('Permanently delete this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}`, { method: 'DELETE' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleCreateClient = async () => {
try {
const models = clientForm.allowed_models ? clientForm.allowed_models.split(',').map(s => s.trim()).filter(Boolean) : null;
const body: any = { user_id: clientForm.user_id, name: clientForm.name, allowed_models: models, rate_limit: clientForm.rate_limit };
const res = await api('/admin/developer/openapi-clients/', { method: 'POST', body: JSON.stringify(body) });
setShowClientModal(false);
setClientForm({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
loadClients(); loadStats();
if (res.data?.client_secret) {
alert(`Client created! Secret: ${res.data.client_secret}\nSave this secret - it won't be shown again.`);
}
} catch (e: any) { alert(e.message); }
};
const handleToggleClient = async (client: OpenApiClient) => {
const newStatus = client.status === 'active' ? 'disabled' : 'active';
try {
await api(`/admin/developer/openapi-clients/${client.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadClients();
} catch (e: any) { alert(e.message); }
};
const handleDeleteClient = async (id: string) => {
if (!confirm('Delete this OpenAPI client?')) return;
try {
await api(`/admin/developer/openapi-clients/${id}`, { method: 'DELETE' });
loadClients(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleResetSecret = async (id: string) => {
if (!confirm('Reset client secret? The old secret will be invalidated.')) return;
try {
const res = await api(`/admin/developer/openapi-clients/${id}/reset-secret`, { method: 'POST' });
if (res.data?.client_secret) alert(`New secret: ${res.data.client_secret}`);
} catch (e: any) { alert(e.message); }
};
const handleClearLogs = async () => {
if (!confirm('Clear ALL slow request logs?')) return;
try {
await api('/admin/developer/slow-logs', { method: 'DELETE' });
loadSlowLogs(); loadStats();
} catch (e: any) { alert(e.message); }
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'revoked' ? 'badge-revoked' : 'badge-disabled';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者API管理</h2>
<p>管理API密钥、OpenAPI客户端、调用统计和慢请求日志</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="dev-stats-grid">
<div className="dev-stat-card">
<div className="stat-icon icon-key">🔑</div>
<div className="stat-info">
<span className="stat-value">{stats.api_keys.total}</span>
<span className="stat-label">API密钥总数</span>
<span className="stat-sub">活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-client">🔌</div>
<div className="stat-info">
<span className="stat-value">{stats.openapi_clients.total}</span>
<span className="stat-label">OpenAPI客户端</span>
<span className="stat-sub">活跃 {stats.openapi_clients.active}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-call">📞</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.total_calls.toLocaleString()}</span>
<span className="stat-label">API总调用</span>
<span className="stat-sub">今日 {stats.call_stats.today_calls.toLocaleString()}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-error">⚠️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.error_rate}%</span>
<span className="stat-label">错误率</span>
<span className="stat-sub">错误 {stats.call_stats.total_errors.toLocaleString()} 次</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-time">⏱️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.avg_response_time}s</span>
<span className="stat-label">平均响应时间</span>
<span className="stat-sub">慢请求 {stats.slow_logs.total} 条</span>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'keys' ? 'active' : ''}`} onClick={() => setActiveTab('keys')}>API密钥</button>
<button className={`dev-tab ${activeTab === 'clients' ? 'active' : ''}`} onClick={() => setActiveTab('clients')}>OpenAPI客户端</button>
<button className={`dev-tab ${activeTab === 'stats' ? 'active' : ''}`} onClick={() => setActiveTab('stats')}>调用统计</button>
<button className={`dev-tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>慢请求日志</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* API Keys Tab */}
{activeTab === 'keys' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索密钥名称/用户..." value={keySearch} onChange={e => setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} />
<select className="dev-select" value={keyStatusFilter} onChange={e => setKeyStatusFilter(e.target.value)}>
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="revoked">已撤销</option>
</select>
<button className="dev-btn primary" onClick={() => setShowKeyModal(true)}>+ 新建密钥</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>API Key</th><th>用户</th><th>速率限制</th><th>状态</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{keys.items.map(k => (
<tr key={k.id}>
<td>{k.name || '-'}</td>
<td className="mono-text">{k.api_key.slice(0, 12)}...</td>
<td>{k.username || k.user_id.slice(0, 8)}</td>
<td>{k.rate_limit}/min</td>
<td>{statusBadge(k.status)}</td>
<td>{fmtTime(k.last_used_at)}</td>
<td>{fmtTime(k.created_at)}</td>
<td className="dev-actions">
{k.status === 'active' && <button className="dev-btn-sm warn" onClick={() => handleRevokeKey(k.id)}>撤销</button>}
<button className="dev-btn-sm danger" onClick={() => handleDeleteKey(k.id)}>删除</button>
</td>
</tr>
))}
{keys.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无API密钥</td></tr>}
</tbody>
</table>
{keys.total > 20 && (
<div className="dev-pagination">
<button disabled={keys.page <= 1} onClick={() => loadKeys(keys.page - 1)}>上一页</button>
<span>第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页</span>
<button disabled={keys.page >= Math.ceil(keys.total / 20)} onClick={() => loadKeys(keys.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* OpenAPI Clients Tab */}
{activeTab === 'clients' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索客户端名称/ID..." value={clientSearch} onChange={e => setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} />
<button className="dev-btn primary" onClick={() => setShowClientModal(true)}>+ 新建客户端</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>Client ID</th><th>用户</th><th>允许模型</th><th>速率限制</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{clients.items.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td className="mono-text">{c.client_id}</td>
<td>{c.username || c.user_id.slice(0, 8)}</td>
<td>{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}</td>
<td>{c.rate_limit}/min</td>
<td>{statusBadge(c.status)}</td>
<td>{fmtTime(c.created_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleToggleClient(c)}>{c.status === 'active' ? '禁用' : '启用'}</button>
<button className="dev-btn-sm warn" onClick={() => handleResetSecret(c.id)}>重置密钥</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteClient(c.id)}>删除</button>
</td>
</tr>
))}
{clients.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无OpenAPI客户端</td></tr>}
</tbody>
</table>
{clients.total > 20 && (
<div className="dev-pagination">
<button disabled={clients.page <= 1} onClick={() => loadClients(clients.page - 1)}>上一页</button>
<span>第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页</span>
<button disabled={clients.page >= Math.ceil(clients.total / 20)} onClick={() => loadClients(clients.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Call Stats Tab */}
{activeTab === 'stats' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={statSearch} onChange={e => setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} />
<select className="dev-select" value={statSortBy} onChange={e => setStatSortBy(e.target.value)}>
<option value="call_count">按调用次数</option>
<option value="error_count">按错误数</option>
<option value="total_response_time">按总耗时</option>
<option value="last_called_at">按最后调用</option>
</select>
</div>
<table className="dev-table">
<thead>
<tr><th>端点</th><th>方法</th><th>调用次数</th><th>错误数</th><th>错误率</th><th>平均耗时</th><th>最后调用</th></tr>
</thead>
<tbody>
{callStats.items.map(s => (
<tr key={s.id}>
<td className="mono-text">{s.endpoint}</td>
<td><span className={`method-badge method-${s.method.toLowerCase()}`}>{s.method}</span></td>
<td>{s.call_count.toLocaleString()}</td>
<td className={s.error_count > 0 ? 'text-danger' : ''}>{s.error_count}</td>
<td className={s.error_rate > 5 ? 'text-danger' : ''}>{s.error_rate}%</td>
<td>{s.avg_response_time}s</td>
<td>{fmtTime(s.last_called_at)}</td>
</tr>
))}
{callStats.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无调用统计</td></tr>}
</tbody>
</table>
{callStats.total > 20 && (
<div className="dev-pagination">
<button disabled={callStats.page <= 1} onClick={() => loadCallStats(callStats.page - 1)}>上一页</button>
<span>第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页</span>
<button disabled={callStats.page >= Math.ceil(callStats.total / 20)} onClick={() => loadCallStats(callStats.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Slow Logs Tab */}
{activeTab === 'logs' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={logSearch} onChange={e => setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} />
<button className="dev-btn danger" onClick={handleClearLogs}>清空日志</button>
</div>
<table className="dev-table">
<thead>
<tr><th>请求ID</th><th>方法</th><th>端点</th><th>状态码</th><th>响应时间</th><th>阈值</th><th>时间</th></tr>
</thead>
<tbody>
{slowLogs.items.map(l => (
<tr key={l.id}>
<td className="mono-text">{l.request_id?.slice(0, 12) || '-'}</td>
<td><span className={`method-badge method-${l.method.toLowerCase()}`}>{l.method}</span></td>
<td className="mono-text">{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}</td>
<td className={l.status_code >= 400 ? 'text-danger' : ''}>{l.status_code}</td>
<td className="text-warn">{l.response_time}s</td>
<td>{l.threshold}s</td>
<td>{fmtTime(l.created_at)}</td>
</tr>
))}
{slowLogs.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无慢请求日志</td></tr>}
</tbody>
</table>
{slowLogs.total > 20 && (
<div className="dev-pagination">
<button disabled={slowLogs.page <= 1} onClick={() => loadSlowLogs(slowLogs.page - 1)}>上一页</button>
<span>第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页</span>
<button disabled={slowLogs.page >= Math.ceil(slowLogs.total / 20)} onClick={() => loadSlowLogs(slowLogs.page + 1)}>下一页</button>
</div>
)}
</div>
)}
</div>
{/* Create API Key Modal */}
{showKeyModal && (
<div className="dev-modal-overlay" onClick={() => setShowKeyModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建API密钥</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={keyForm.user_id} onChange={e => setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" />
</div>
<div className="dev-form-group">
<label>描述</label>
<input className="dev-input" value={keyForm.description} onChange={e => setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={keyForm.rate_limit} onChange={e => setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowKeyModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateKey} disabled={!keyForm.user_id}>创建</button>
</div>
</div>
</div>
)}
{/* Create Client Modal */}
{showClientModal && (
<div className="dev-modal-overlay" onClick={() => setShowClientModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建OpenAPI客户端</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={clientForm.user_id} onChange={e => setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称 *</label>
<input className="dev-input" value={clientForm.name} onChange={e => setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" />
</div>
<div className="dev-form-group">
<label>允许的模型 (逗号分隔,留空=全部)</label>
<input className="dev-input" value={clientForm.allowed_models} onChange={e => setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={clientForm.rate_limit} onChange={e => setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowClientModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateClient} disabled={!clientForm.user_id || !clientForm.name}>创建</button>
</div>
</div>
</div>
)}
</div>
);
}