From e7d1c39c18491c5b2acb1ae1d0d5ac4f3ae6bf7c Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 3 Jul 2026 19:04:49 +0800 Subject: [PATCH] feat: Phase 3.4 Developer API Management - API Keys, OpenAPI Clients, Call Stats, Slow Logs --- .../python/app/api/admin/developer_admin.py | 658 ++++++++++++++++++ backend/python/app/models.py | 5 + backend/python/main.py | 3 +- frontend/src/App.tsx | 4 +- frontend/src/api/index.ts | 34 + .../src/components/layout/AdminLayout.tsx | 6 +- frontend/src/pages/admin/Developer.css | 138 ++++ frontend/src/pages/admin/Developer.tsx | 515 ++++++++++++++ 8 files changed, 1359 insertions(+), 4 deletions(-) create mode 100644 backend/python/app/api/admin/developer_admin.py create mode 100644 frontend/src/pages/admin/Developer.css create mode 100644 frontend/src/pages/admin/Developer.tsx diff --git a/backend/python/app/api/admin/developer_admin.py b/backend/python/app/api/admin/developer_admin.py new file mode 100644 index 0000000..776dd71 --- /dev/null +++ b/backend/python/app/api/admin/developer_admin.py @@ -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 + ], + } + } diff --git a/backend/python/app/models.py b/backend/python/app/models.py index c853872..da31e65 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -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="最后使用时间") # ============================================================ diff --git a/backend/python/main.py b/backend/python/main.py index 8130894..ada2a88 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -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"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f017da2..a5add7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4b5aadd..9d5d0fb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -542,6 +542,40 @@ export const adminAPI = { testAdminMcpServer: (id: string, data?: any) => post(`/admin/mcp/${id}/test`, data || {}), getAdminMcpServerTools: (id: string) => get(`/admin/mcp/${id}/tools`), getAdminMcpServerResources: (id: string) => get(`/admin/mcp/${id}/resources`), + // Developer API Management + getAdminDeveloperStats: () => get('/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(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`); + }, + createAdminApiKey: (data: any) => post('/admin/developer/api-keys/', data), + updateAdminApiKey: (id: string, data: any) => put(`/admin/developer/api-keys/${id}`, data), + revokeAdminApiKey: (id: string) => post(`/admin/developer/api-keys/${id}/revoke`), + deleteAdminApiKey: (id: string) => del(`/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(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`); + }, + createAdminOpenApiClient: (data: any) => post('/admin/developer/openapi-clients/', data), + updateAdminOpenApiClient: (id: string, data: any) => put(`/admin/developer/openapi-clients/${id}`, data), + resetAdminOpenApiClientSecret: (id: string) => post(`/admin/developer/openapi-clients/${id}/reset-secret`), + deleteAdminOpenApiClient: (id: string) => del(`/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(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`); + }, + getAdminCallStatsOverview: () => get('/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(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`); + }, + clearAdminSlowLogs: () => del('/admin/developer/slow-logs'), + getAdminSlowLogsStats: () => get('/admin/developer/slow-logs/stats'), + }; // ===== AI自进化API ===== diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 24cf913..a24bd75 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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 = { skills: , automation: , storage: , -}; + + developer: (),}; diff --git a/frontend/src/pages/admin/Developer.css b/frontend/src/pages/admin/Developer.css new file mode 100644 index 0000000..ce23efc --- /dev/null +++ b/frontend/src/pages/admin/Developer.css @@ -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; } +} diff --git a/frontend/src/pages/admin/Developer.tsx b/frontend/src/pages/admin/Developer.tsx new file mode 100644 index 0000000..412b0a1 --- /dev/null +++ b/frontend/src/pages/admin/Developer.tsx @@ -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 { 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(null); + const [loading, setLoading] = useState(false); + + // API Keys state + const [keys, setKeys] = useState>({ 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>({ 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>({ 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>({ 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 {status}; + }; + + return ( +
+
+

开发者API管理

+

管理API密钥、OpenAPI客户端、调用统计和慢请求日志

+
+ + {/* Stats Cards */} + {stats && ( +
+
+
🔑
+
+ {stats.api_keys.total} + API密钥总数 + 活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked} +
+
+
+
🔌
+
+ {stats.openapi_clients.total} + OpenAPI客户端 + 活跃 {stats.openapi_clients.active} +
+
+
+
📞
+
+ {stats.call_stats.total_calls.toLocaleString()} + API总调用 + 今日 {stats.call_stats.today_calls.toLocaleString()} +
+
+
+
⚠️
+
+ {stats.call_stats.error_rate}% + 错误率 + 错误 {stats.call_stats.total_errors.toLocaleString()} 次 +
+
+
+
⏱️
+
+ {stats.call_stats.avg_response_time}s + 平均响应时间 + 慢请求 {stats.slow_logs.total} 条 +
+
+
+ )} + + {/* Tab Navigation */} +
+ + + + +
+ + {/* Tab Content */} +
+ {loading &&
加载中...
} + + {/* API Keys Tab */} + {activeTab === 'keys' && !loading && ( +
+
+ setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} /> + + +
+ + + + + + {keys.items.map(k => ( + + + + + + + + + + + ))} + {keys.items.length === 0 && } + +
名称API Key用户速率限制状态最后使用创建时间操作
{k.name || '-'}{k.api_key.slice(0, 12)}...{k.username || k.user_id.slice(0, 8)}{k.rate_limit}/min{statusBadge(k.status)}{fmtTime(k.last_used_at)}{fmtTime(k.created_at)} + {k.status === 'active' && } + +
暂无API密钥
+ {keys.total > 20 && ( +
+ + 第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页 + +
+ )} +
+ )} + + {/* OpenAPI Clients Tab */} + {activeTab === 'clients' && !loading && ( +
+
+ setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} /> + +
+ + + + + + {clients.items.map(c => ( + + + + + + + + + + + ))} + {clients.items.length === 0 && } + +
名称Client ID用户允许模型速率限制状态创建时间操作
{c.name}{c.client_id}{c.username || c.user_id.slice(0, 8)}{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}{c.rate_limit}/min{statusBadge(c.status)}{fmtTime(c.created_at)} + + + +
暂无OpenAPI客户端
+ {clients.total > 20 && ( +
+ + 第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页 + +
+ )} +
+ )} + + {/* Call Stats Tab */} + {activeTab === 'stats' && !loading && ( +
+
+ setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} /> + +
+ + + + + + {callStats.items.map(s => ( + + + + + + + + + + ))} + {callStats.items.length === 0 && } + +
端点方法调用次数错误数错误率平均耗时最后调用
{s.endpoint}{s.method}{s.call_count.toLocaleString()} 0 ? 'text-danger' : ''}>{s.error_count} 5 ? 'text-danger' : ''}>{s.error_rate}%{s.avg_response_time}s{fmtTime(s.last_called_at)}
暂无调用统计
+ {callStats.total > 20 && ( +
+ + 第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页 + +
+ )} +
+ )} + + {/* Slow Logs Tab */} + {activeTab === 'logs' && !loading && ( +
+
+ setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} /> + +
+ + + + + + {slowLogs.items.map(l => ( + + + + + + + + + + ))} + {slowLogs.items.length === 0 && } + +
请求ID方法端点状态码响应时间阈值时间
{l.request_id?.slice(0, 12) || '-'}{l.method}{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}= 400 ? 'text-danger' : ''}>{l.status_code}{l.response_time}s{l.threshold}s{fmtTime(l.created_at)}
暂无慢请求日志
+ {slowLogs.total > 20 && ( +
+ + 第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页 + +
+ )} +
+ )} +
+ + {/* Create API Key Modal */} + {showKeyModal && ( +
setShowKeyModal(false)}> +
e.stopPropagation()}> +

新建API密钥

+
+ + setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" /> +
+
+ + setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" /> +
+
+ + setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" /> +
+
+ + setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} /> +
+
+ + +
+
+
+ )} + + {/* Create Client Modal */} + {showClientModal && ( +
setShowClientModal(false)}> +
e.stopPropagation()}> +

新建OpenAPI客户端

+
+ + setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" /> +
+
+ + setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" /> +
+
+ + setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" /> +
+
+ + setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} /> +
+
+ + +
+
+
+ )} +
+ ); +}