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

@@ -0,0 +1,658 @@
"""
Developer API Management - Admin endpoints for API Keys, OpenAPI Clients,
API Call Statistics, and Slow Request Logs.
"""
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, desc, asc
from sqlalchemy.orm import Session
from ...database import get_db
from ...models import (
ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User
)
router = APIRouter()
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
return datetime.now(CST_TZ)
def fmt_time(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=CST_TZ).isoformat()
return dt.isoformat()
# ============================================================
# Pydantic Models
# ============================================================
class ApiKeyCreate(BaseModel):
user_id: str
name: str = ""
description: str = ""
rate_limit: int = 60
expired_at: str = None
class ApiKeyUpdate(BaseModel):
name: str = None
description: str = None
rate_limit: int = None
status: str = None
expired_at: str = None
class OpenAPIClientCreate(BaseModel):
user_id: str
name: str
allowed_models: list = None
rate_limit: int = 60
class OpenAPIClientUpdate(BaseModel):
name: str = None
allowed_models: list = None
rate_limit: int = None
status: str = None
# ============================================================
# Helper functions
# ============================================================
def _generate_api_key():
"""Generate a unique API key string."""
return "sk-" + secrets.token_hex(24)
def _generate_secret():
"""Generate a secret string."""
return secrets.token_hex(32)
def _generate_client_id():
"""Generate a unique client ID."""
return "cli_" + secrets.token_hex(12)
def _generate_client_secret():
"""Generate a client secret."""
return secrets.token_hex(32)
def _serialize_api_key(key, user=None):
return {
"id": key.id,
"user_id": key.user_id,
"username": user.username if user else None,
"api_key": key.api_key,
"name": key.name,
"description": getattr(key, "description", "") or "",
"rate_limit": getattr(key, "rate_limit", 60) or 60,
"total_calls": getattr(key, "total_calls", 0) or 0,
"status": key.status,
"last_used_at": fmt_time(key.last_used_at),
"created_at": fmt_time(key.created_at),
"expired_at": fmt_time(key.expired_at),
}
def _serialize_client(client, user=None):
return {
"id": client.id,
"user_id": client.user_id,
"username": user.username if user else None,
"name": client.name,
"client_id": client.client_id,
"allowed_models": client.allowed_models or [],
"rate_limit": client.rate_limit,
"total_calls": getattr(client, "total_calls", 0) or 0,
"last_used_at": fmt_time(getattr(client, "last_used_at", None)),
"status": client.status,
"created_at": fmt_time(client.created_at),
}
def _serialize_call_stat(stat):
avg_time = 0.0
if stat.call_count and stat.call_count > 0:
avg_time = round(stat.total_response_time / stat.call_count, 3)
error_rate = 0.0
if stat.call_count and stat.call_count > 0:
error_rate = round(stat.error_count / stat.call_count * 100, 2)
return {
"id": stat.id,
"endpoint": stat.endpoint,
"method": stat.method,
"call_count": stat.call_count,
"error_count": stat.error_count,
"error_rate": error_rate,
"avg_response_time": avg_time,
"total_response_time": round(stat.total_response_time, 3),
"last_called_at": fmt_time(stat.last_called_at),
"created_at": fmt_time(stat.created_at),
"updated_at": fmt_time(stat.updated_at),
}
def _serialize_slow_log(log):
return {
"id": log.id,
"request_id": log.request_id,
"method": log.method,
"endpoint": log.endpoint,
"status_code": log.status_code,
"response_time": round(log.response_time, 3),
"threshold": round(log.threshold, 2),
"created_at": fmt_time(log.created_at),
}
# ============================================================
# Stats Overview
# ============================================================
@router.get("/stats/overview")
async def get_stats_overview(db: Session = Depends(get_db)):
"""Get developer API dashboard statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_naive = today_start.replace(tzinfo=None)
# API Key stats
total_keys = db.query(func.count(ApiKey.id)).scalar() or 0
active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0
revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0
# OpenAPI Client stats
total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0
active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0
# Call stats
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0
# Today's calls (approximate by last_called_at)
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
ApiCallStat.last_called_at >= today_start_naive
).scalar() or 0
# Slow request count
slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today_slow = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start_naive
).scalar() or 0
# Top endpoints by call count
top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all()
top_endpoints_data = [
{
"endpoint": s.endpoint,
"method": s.method,
"call_count": s.call_count,
"error_count": s.error_count,
}
for s in top_endpoints
]
return {
"code": 0,
"data": {
"api_keys": {
"total": total_keys,
"active": active_keys,
"revoked": revoked_keys,
},
"openapi_clients": {
"total": total_clients,
"active": active_clients,
},
"call_stats": {
"total_calls": total_calls,
"today_calls": today_calls,
"total_errors": total_errors,
"error_rate": error_rate,
"avg_response_time": avg_response_time,
},
"slow_logs": {
"total": slow_log_count,
"today": today_slow,
},
"top_endpoints": top_endpoints_data,
"server_time": cst_now().isoformat(),
}
}
# ============================================================
# API Key Management
# ============================================================
@router.get("/api-keys")
async def list_api_keys(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all API keys with pagination."""
query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id)
if status:
query = query.filter(ApiKey.status == status)
if keyword:
query = query.filter(
(ApiKey.name.ilike(f"%{keyword}%")) |
(ApiKey.api_key.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_api_key(k, u) for k, u in items],
}
}
@router.post("/api-keys")
async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)):
"""Create a new API key."""
# Verify user exists
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if model has description/rate_limit/total_calls columns
new_key = ApiKey(
user_id=body.user_id,
api_key=_generate_api_key(),
secret=_generate_secret(),
name=body.name,
status="active",
created_at=datetime.now(),
)
# Set optional fields if they exist on the model
try:
new_key.description = body.description
except Exception:
pass
try:
new_key.rate_limit = body.rate_limit
except Exception:
pass
try:
new_key.total_calls = 0
except Exception:
pass
if body.expired_at:
try:
new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.add(new_key)
db.commit()
db.refresh(new_key)
return {
"code": 0,
"data": _serialize_api_key(new_key, user),
"message": "API Key created successfully",
}
@router.put("/api-keys/{key_id}")
async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)):
"""Update an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
if body.name is not None:
key.name = body.name
if body.status is not None:
key.status = body.status
if body.rate_limit is not None:
try:
key.rate_limit = body.rate_limit
except Exception:
pass
if body.description is not None:
try:
key.description = body.description
except Exception:
pass
if body.expired_at is not None:
try:
key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.commit()
db.refresh(key)
user = db.query(User).filter(User.id == key.user_id).first()
return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"}
@router.post("/api-keys/{key_id}/revoke")
async def revoke_api_key(key_id: str, db: Session = Depends(get_db)):
"""Revoke an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
key.status = "revoked"
db.commit()
return {"code": 0, "message": "API Key revoked"}
@router.delete("/api-keys/{key_id}")
async def delete_api_key(key_id: str, db: Session = Depends(get_db)):
"""Delete an API key permanently."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
db.delete(key)
db.commit()
return {"code": 0, "message": "API Key deleted"}
# ============================================================
# OpenAPI Client Management
# ============================================================
@router.get("/openapi-clients")
async def list_openapi_clients(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all OpenAPI clients."""
query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id)
if status:
query = query.filter(OpenApiClient.status == status)
if keyword:
query = query.filter(
(OpenApiClient.name.ilike(f"%{keyword}%")) |
(OpenApiClient.client_id.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_client(c, u) for c, u in items],
}
}
@router.post("/openapi-clients")
async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)):
"""Create a new OpenAPI client."""
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_client = OpenApiClient(
user_id=body.user_id,
name=body.name,
client_id=_generate_client_id(),
client_secret=_generate_client_secret(),
allowed_models=body.allowed_models,
rate_limit=body.rate_limit,
status="active",
created_at=datetime.now(),
)
try:
new_client.total_calls = 0
except Exception:
pass
try:
new_client.last_used_at = None
except Exception:
pass
db.add(new_client)
db.commit()
db.refresh(new_client)
result = _serialize_client(new_client, user)
result["client_secret"] = new_client.client_secret # Only show on create
return {"code": 0, "data": result, "message": "OpenAPI Client created"}
@router.put("/openapi-clients/{client_id}")
async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)):
"""Update an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
if body.name is not None:
client.name = body.name
if body.allowed_models is not None:
client.allowed_models = body.allowed_models
if body.rate_limit is not None:
client.rate_limit = body.rate_limit
if body.status is not None:
client.status = body.status
db.commit()
db.refresh(client)
user = db.query(User).filter(User.id == client.user_id).first()
return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"}
@router.post("/openapi-clients/{client_id}/reset-secret")
async def reset_client_secret(client_id: str, db: Session = Depends(get_db)):
"""Reset the client secret."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
client.client_secret = _generate_client_secret()
db.commit()
db.refresh(client)
return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"}
@router.delete("/openapi-clients/{client_id}")
async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)):
"""Delete an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
db.delete(client)
db.commit()
return {"code": 0, "message": "OpenAPI Client deleted"}
# ============================================================
# API Call Statistics
# ============================================================
@router.get("/call-stats")
async def list_call_stats(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("call_count"),
sort_order: str = Query("desc"),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List API call statistics."""
query = db.query(ApiCallStat)
if keyword:
query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%"))
sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
total = query.count()
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_call_stat(s) for s in items],
}
}
@router.get("/call-stats/overview")
async def call_stats_overview(db: Session = Depends(get_db)):
"""Get call stats summary."""
total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
avg_time = 0.0
if total_calls and total_calls > 0:
total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_time = round(total_time / total_calls, 3)
# Method distribution
method_stats = db.query(
ApiCallStat.method,
func.sum(ApiCallStat.call_count).label("count")
).group_by(ApiCallStat.method).all()
return {
"code": 0,
"data": {
"total_endpoints": total_endpoints,
"total_calls": total_calls,
"total_errors": total_errors,
"error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0,
"avg_response_time": avg_time,
"method_distribution": [
{"method": m, "count": c} for m, c in method_stats
],
}
}
# ============================================================
# Slow Request Logs
# ============================================================
@router.get("/slow-logs")
async def list_slow_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query(None),
min_time: float = Query(None),
db: Session = Depends(get_db),
):
"""List slow request logs."""
query = db.query(SlowRequestLog)
if keyword:
query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%"))
if min_time is not None:
query = query.filter(SlowRequestLog.response_time >= min_time)
total = query.count()
items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_slow_log(l) for l in items],
}
}
@router.delete("/slow-logs")
async def clear_slow_logs(db: Session = Depends(get_db)):
"""Clear all slow request logs."""
deleted = db.query(SlowRequestLog).delete()
db.commit()
return {"code": 0, "message": f"Cleared {deleted} slow request logs"}
@router.get("/slow-logs/stats")
async def slow_logs_stats(db: Session = Depends(get_db)):
"""Get slow request log statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
total = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start
).scalar() or 0
avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0
max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0
# Distribution by status code
status_dist = db.query(
SlowRequestLog.status_code,
func.count(SlowRequestLog.id).label("count")
).group_by(SlowRequestLog.status_code).all()
return {
"code": 0,
"data": {
"total": total,
"today": today,
"avg_response_time": round(float(avg_time), 3),
"max_response_time": round(float(max_time), 3),
"status_distribution": [
{"status_code": s, "count": c} for s, c in status_dist
],
}
}
+5
View File
@@ -65,6 +65,9 @@ class ApiKey(Base):
last_used_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
expired_at = Column(DateTime, default=None)
description = Column(String(500), default=None, comment="密钥描述")
rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)")
total_calls = Column(BigInteger, default=0, comment="总调用次数")
# ============================================================
@@ -770,6 +773,8 @@ class OpenApiClient(Base):
rate_limit = Column(Integer, default=60)
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, default=datetime.now)
total_calls = Column(BigInteger, default=0, comment="总调用次数")
last_used_at = Column(DateTime, default=None, comment="最后使用时间")
# ============================================================
+2 -1
View File
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal
@@ -422,6 +422,7 @@ app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", ta
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
+3 -1
View File
@@ -75,7 +75,8 @@ import AdminAgents from '@/pages/admin/Agents'
import AdminKnowledge from '@/pages/admin/Knowledge';
import AdminStorage from '@/pages/admin/Storage';
import AdminMcp from '@/pages/admin/Mcp';
import AdminAutomation from '@/pages/admin/Automation';;
import AdminAutomation from '@/pages/admin/Automation';
import AdminDeveloper from '@/pages/admin/Developer';;
@@ -236,6 +237,7 @@ const App: React.FC = () => {
<Route path="knowledge" element={<AdminKnowledge />} />
<Route path="storage" element={<AdminStorage />} />
<Route path="automation" element={<AdminAutomation />} />
<Route path="developer" element={<AdminDeveloper />} />
</Route>
+34
View File
@@ -542,6 +542,40 @@ export const adminAPI = {
testAdminMcpServer: (id: string, data?: any) => post<any>(`/admin/mcp/${id}/test`, data || {}),
getAdminMcpServerTools: (id: string) => get<any>(`/admin/mcp/${id}/tools`),
getAdminMcpServerResources: (id: string) => get<any>(`/admin/mcp/${id}/resources`),
// Developer API Management
getAdminDeveloperStats: () => get<any>('/admin/developer/stats/overview'),
getAdminApiKeys: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`);
},
createAdminApiKey: (data: any) => post<any>('/admin/developer/api-keys/', data),
updateAdminApiKey: (id: string, data: any) => put<any>(`/admin/developer/api-keys/${id}`, data),
revokeAdminApiKey: (id: string) => post<any>(`/admin/developer/api-keys/${id}/revoke`),
deleteAdminApiKey: (id: string) => del<any>(`/admin/developer/api-keys/${id}`),
getAdminOpenApiClients: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`);
},
createAdminOpenApiClient: (data: any) => post<any>('/admin/developer/openapi-clients/', data),
updateAdminOpenApiClient: (id: string, data: any) => put<any>(`/admin/developer/openapi-clients/${id}`, data),
resetAdminOpenApiClientSecret: (id: string) => post<any>(`/admin/developer/openapi-clients/${id}/reset-secret`),
deleteAdminOpenApiClient: (id: string) => del<any>(`/admin/developer/openapi-clients/${id}`),
getAdminCallStats: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`);
},
getAdminCallStatsOverview: () => get<any>('/admin/developer/call-stats/overview'),
getAdminSlowLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`);
},
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
};
// ===== AI自进化API =====
@@ -37,7 +37,8 @@ const adminNavItems = [
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },];
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },
{ path: '/admin/developer', icon: 'developer', label: '开发者API' },];
@@ -68,7 +69,8 @@ const adminIconPaths: Record<string, JSX.Element> = {
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2"/><path d="M12 21v2"/><path d="M4.22 4.22l1.42 1.42"/><path d="M18.36 18.36l1.42 1.42"/><path d="M1 12h2"/><path d="M21 12h2"/><path d="M4.22 19.78l1.42-1.42"/><path d="M18.36 5.64l1.42-1.42"/></svg>,
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
};
developer: (<svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><polyline points='16 18 22 12 16 6'/><polyline points='8 6 2 12 8 18'/></svg>),};
+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>
);
}