Phase 3.9: Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard
This commit is contained in:
1 parent
065d2f05f6
commit
49dcea0a11
6 files changed
+1460
No files matched your search
@@ -0,0 +1,899 @@
|
||||
"""
|
||||
Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard.
|
||||
Phase 3.9: Extends the developer module with webhook management,
|
||||
SDK download center, interactive API documentation, and enhanced analytics.
|
||||
"""
|
||||
import json
|
||||
import secrets
|
||||
import hashlib
|
||||
import hmac
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_db, SessionLocal
|
||||
from ...auth import get_current_admin
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _execute_raw(db, sql, params=None):
|
||||
"""Execute raw SQL and return list of dicts."""
|
||||
result = db.execute(text(sql), params or {})
|
||||
rows = []
|
||||
for row in result:
|
||||
rows.append(dict(row._mapping))
|
||||
return rows
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Pydantic Models
|
||||
# ============================================================
|
||||
|
||||
class WebhookCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
events: list = []
|
||||
secret: str = ""
|
||||
headers: dict = {}
|
||||
|
||||
|
||||
class WebhookUpdate(BaseModel):
|
||||
name: str = None
|
||||
url: str = None
|
||||
events: list = None
|
||||
secret: str = None
|
||||
headers: dict = None
|
||||
status: str = None
|
||||
|
||||
|
||||
class WebhookTest(BaseModel):
|
||||
event: str = "test"
|
||||
payload: dict = {"message": "Test webhook delivery"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Webhook Management
|
||||
# ============================================================
|
||||
|
||||
SUPPORTED_EVENTS = [
|
||||
{"value": "app.created", "label": "App Created", "desc": "Triggered when a new AI app is created"},
|
||||
{"value": "app.published", "label": "App Published", "desc": "Triggered when an app is published to market"},
|
||||
{"value": "agent.created", "label": "Agent Created", "desc": "Triggered when a new agent is created"},
|
||||
{"value": "knowledge.updated", "label": "Knowledge Updated", "desc": "Triggered when knowledge base is updated"},
|
||||
{"value": "api_key.created", "label": "API Key Created", "desc": "Triggered when a new API key is generated"},
|
||||
{"value": "api_key.revoked", "label": "API Key Revoked", "desc": "Triggered when an API key is revoked"},
|
||||
{"value": "user.registered", "label": "User Registered", "desc": "Triggered when a new user registers"},
|
||||
{"value": "model.connected", "label": "Model Connected", "desc": "Triggered when a model connection test passes"},
|
||||
{"value": "workflow.completed", "label": "Workflow Completed", "desc": "Triggered when a workflow execution finishes"},
|
||||
{"value": "storage.threshold", "label": "Storage Threshold", "desc": "Triggered when storage usage exceeds threshold"},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/webhooks", summary="List webhooks")
|
||||
async def list_webhooks(
|
||||
status: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all webhook configurations."""
|
||||
where_clause = "WHERE 1=1"
|
||||
params = {}
|
||||
if status:
|
||||
where_clause += " AND status = :status"
|
||||
params["status"] = status
|
||||
|
||||
count_sql = f"SELECT COUNT(*) as cnt FROM webhook_configs {where_clause}"
|
||||
count_rows = _execute_raw(db, count_sql, params)
|
||||
total = count_rows[0]["cnt"] if count_rows else 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
list_sql = f"""SELECT id, name, url, events, secret, headers, status,
|
||||
last_triggered_at, last_status, total_deliveries, success_count, fail_count,
|
||||
created_at, updated_at
|
||||
FROM webhook_configs {where_clause}
|
||||
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
|
||||
list_params = dict(params)
|
||||
list_params["limit"] = page_size
|
||||
list_params["offset"] = offset
|
||||
rows = _execute_raw(db, list_sql, list_params)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
events_raw = r.get("events")
|
||||
if isinstance(events_raw, str):
|
||||
try:
|
||||
events_val = json.loads(events_raw)
|
||||
except Exception:
|
||||
events_val = []
|
||||
else:
|
||||
events_val = events_raw or []
|
||||
|
||||
headers_raw = r.get("headers")
|
||||
if isinstance(headers_raw, str):
|
||||
try:
|
||||
headers_val = json.loads(headers_raw)
|
||||
except Exception:
|
||||
headers_val = {}
|
||||
else:
|
||||
headers_val = headers_raw or {}
|
||||
|
||||
success_rate = 0
|
||||
if r.get("total_deliveries", 0) > 0:
|
||||
success_rate = round(r["success_count"] / r["total_deliveries"] * 100, 1)
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"url": r["url"],
|
||||
"events": events_val,
|
||||
"has_secret": bool(r.get("secret")),
|
||||
"headers": headers_val,
|
||||
"status": r["status"],
|
||||
"last_triggered_at": fmt_time(r.get("last_triggered_at")),
|
||||
"last_status": r.get("last_status"),
|
||||
"total_deliveries": r.get("total_deliveries", 0),
|
||||
"success_count": r.get("success_count", 0),
|
||||
"fail_count": r.get("fail_count", 0),
|
||||
"success_rate": success_rate,
|
||||
"created_at": fmt_time(r.get("created_at")),
|
||||
"updated_at": fmt_time(r.get("updated_at")),
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
|
||||
|
||||
|
||||
@router.get("/webhooks/events", summary="List supported webhook events")
|
||||
async def list_webhook_events(
|
||||
admin_user=Depends(get_current_admin),
|
||||
):
|
||||
"""List all supported webhook event types."""
|
||||
return {"code": 0, "data": {"events": SUPPORTED_EVENTS}}
|
||||
|
||||
|
||||
@router.post("/webhooks/", summary="Create webhook")
|
||||
async def create_webhook(
|
||||
body: WebhookCreate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new webhook configuration."""
|
||||
webhook_id = "wh_" + secrets.token_hex(12)
|
||||
now = cst_now()
|
||||
|
||||
secret_val = body.secret if body.secret else "whsec_" + secrets.token_hex(16)
|
||||
|
||||
insert_sql = """INSERT INTO webhook_configs
|
||||
(id, name, url, events, secret, headers, status, last_triggered_at,
|
||||
last_status, total_deliveries, success_count, fail_count, created_at, updated_at)
|
||||
VALUES (:id, :name, :url, :events, :secret, :headers, 'active',
|
||||
NULL, NULL, 0, 0, 0, :now, :now)"""
|
||||
params = {
|
||||
"id": webhook_id,
|
||||
"name": body.name,
|
||||
"url": body.url,
|
||||
"events": json.dumps(body.events),
|
||||
"secret": secret_val,
|
||||
"headers": json.dumps(body.headers),
|
||||
"now": now,
|
||||
}
|
||||
db.execute(text(insert_sql), params)
|
||||
db.commit()
|
||||
|
||||
return {"code": 0, "data": {"id": webhook_id, "name": body.name, "secret": secret_val, "message": "Webhook created"}}
|
||||
|
||||
|
||||
@router.put("/webhooks/{webhook_id}", summary="Update webhook")
|
||||
async def update_webhook(
|
||||
webhook_id: str,
|
||||
body: WebhookUpdate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an existing webhook configuration."""
|
||||
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
|
||||
rows = _execute_raw(db, check_sql, {"id": webhook_id})
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
updates = []
|
||||
params = {"id": webhook_id, "now": cst_now()}
|
||||
if body.name is not None:
|
||||
updates.append("name = :name")
|
||||
params["name"] = body.name
|
||||
if body.url is not None:
|
||||
updates.append("url = :url")
|
||||
params["url"] = body.url
|
||||
if body.events is not None:
|
||||
updates.append("events = :events")
|
||||
params["events"] = json.dumps(body.events)
|
||||
if body.secret is not None:
|
||||
updates.append("secret = :secret")
|
||||
params["secret"] = body.secret
|
||||
if body.headers is not None:
|
||||
updates.append("headers = :headers")
|
||||
params["headers"] = json.dumps(body.headers)
|
||||
if body.status is not None:
|
||||
updates.append("status = :status")
|
||||
params["status"] = body.status
|
||||
|
||||
if not updates:
|
||||
return {"code": 0, "message": "No changes"}
|
||||
|
||||
updates.append("updated_at = :now")
|
||||
update_sql = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = :id"
|
||||
db.execute(text(update_sql), params)
|
||||
db.commit()
|
||||
|
||||
return {"code": 0, "message": "Webhook updated"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}", summary="Delete webhook")
|
||||
async def delete_webhook(
|
||||
webhook_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a webhook configuration."""
|
||||
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
|
||||
rows = _execute_raw(db, check_sql, {"id": webhook_id})
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
db.execute(text("DELETE FROM webhook_configs WHERE id = :id"), {"id": webhook_id})
|
||||
db.commit()
|
||||
|
||||
return {"code": 0, "message": "Webhook deleted"}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test", summary="Test webhook delivery")
|
||||
async def test_webhook(
|
||||
webhook_id: str,
|
||||
body: WebhookTest = None,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Send a test payload to the webhook endpoint."""
|
||||
select_sql = "SELECT * FROM webhook_configs WHERE id = :id"
|
||||
rows = _execute_raw(db, select_sql, {"id": webhook_id})
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
wh = rows[0]
|
||||
url = wh["url"]
|
||||
secret = wh.get("secret", "")
|
||||
|
||||
test_event = body.event if body else "test"
|
||||
test_payload = body.payload if body else {"message": "Test webhook delivery"}
|
||||
full_payload = {
|
||||
"event": test_event,
|
||||
"timestamp": cst_now().isoformat(),
|
||||
"webhook_id": webhook_id,
|
||||
"data": test_payload,
|
||||
}
|
||||
payload_bytes = json.dumps(full_payload).encode("utf-8")
|
||||
|
||||
# Build headers
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers_raw = wh.get("headers")
|
||||
if isinstance(headers_raw, str):
|
||||
try:
|
||||
headers.update(json.loads(headers_raw))
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(headers_raw, dict):
|
||||
headers.update(headers_raw)
|
||||
|
||||
# HMAC signature
|
||||
if secret:
|
||||
signature = hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest()
|
||||
headers["X-Webhook-Signature"] = signature
|
||||
|
||||
# Send request
|
||||
delivery_id = "dlv_" + secrets.token_hex(12)
|
||||
now = cst_now()
|
||||
response_code = None
|
||||
response_body = None
|
||||
response_time_ms = None
|
||||
status = "failed"
|
||||
error_message = None
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
|
||||
start_time = datetime.now()
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
end_time = datetime.now()
|
||||
response_code = resp.getcode()
|
||||
response_body = resp.read().decode("utf-8", errors="replace")[:2000]
|
||||
response_time_ms = int((end_time - start_time).total_seconds() * 1000)
|
||||
if 200 <= response_code < 300:
|
||||
status = "success"
|
||||
except urllib.error.HTTPError as e:
|
||||
response_code = e.code
|
||||
try:
|
||||
response_body = e.read().decode("utf-8", errors="replace")[:2000]
|
||||
except Exception:
|
||||
response_body = str(e)
|
||||
error_message = f"HTTP {e.code}: {str(e)[:200]}"
|
||||
except Exception as e:
|
||||
error_message = str(e)[:500]
|
||||
|
||||
# Record delivery
|
||||
insert_sql = """INSERT INTO webhook_deliveries
|
||||
(id, webhook_id, event, payload, status, response_code, response_body,
|
||||
response_time_ms, attempts, error_message, created_at)
|
||||
VALUES (:id, :wid, :event, :payload, :status, :rcode, :rbody,
|
||||
:rtime, 1, :err, :now)"""
|
||||
db.execute(text(insert_sql), {
|
||||
"id": delivery_id, "wid": webhook_id, "event": test_event,
|
||||
"payload": json.dumps(full_payload), "status": status,
|
||||
"rcode": response_code, "rbody": response_body,
|
||||
"rtime": response_time_ms, "err": error_message, "now": now,
|
||||
})
|
||||
|
||||
# Update webhook stats
|
||||
total_deliveries = (wh.get("total_deliveries") or 0) + 1
|
||||
success_count = (wh.get("success_count") or 0) + (1 if status == "success" else 0)
|
||||
fail_count = (wh.get("fail_count") or 0) + (1 if status != "success" else 0)
|
||||
db.execute(text("""UPDATE webhook_configs SET
|
||||
total_deliveries = :td, success_count = :sc, fail_count = :fc,
|
||||
last_triggered_at = :now, last_status = :ls, updated_at = :now
|
||||
WHERE id = :id"""), {
|
||||
"td": total_deliveries, "sc": success_count, "fc": fail_count,
|
||||
"now": now, "ls": status, "id": webhook_id,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"delivery_id": delivery_id,
|
||||
"status": status,
|
||||
"response_code": response_code,
|
||||
"response_time_ms": response_time_ms,
|
||||
"response_body": response_body[:500] if response_body else None,
|
||||
"error_message": error_message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/deliveries", summary="Webhook delivery history")
|
||||
async def webhook_deliveries(
|
||||
webhook_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get delivery history for a specific webhook."""
|
||||
count_sql = "SELECT COUNT(*) as cnt FROM webhook_deliveries WHERE webhook_id = :wid"
|
||||
count_rows = _execute_raw(db, count_sql, {"wid": webhook_id})
|
||||
total = count_rows[0]["cnt"] if count_rows else 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
list_sql = """SELECT id, webhook_id, event, payload, status, response_code,
|
||||
response_body, response_time_ms, attempts, error_message, created_at
|
||||
FROM webhook_deliveries WHERE webhook_id = :wid
|
||||
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
|
||||
rows = _execute_raw(db, list_sql, {"wid": webhook_id, "limit": page_size, "offset": offset})
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
payload_raw = r.get("payload")
|
||||
if isinstance(payload_raw, str):
|
||||
try:
|
||||
payload_val = json.loads(payload_raw)
|
||||
except Exception:
|
||||
payload_val = payload_raw
|
||||
else:
|
||||
payload_val = payload_raw
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"event": r["event"],
|
||||
"status": r["status"],
|
||||
"response_code": r.get("response_code"),
|
||||
"response_time_ms": r.get("response_time_ms"),
|
||||
"attempts": r.get("attempts", 0),
|
||||
"error_message": r.get("error_message"),
|
||||
"payload": payload_val,
|
||||
"response_body": (r.get("response_body") or "")[:500],
|
||||
"created_at": fmt_time(r.get("created_at")),
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SDK Download Center
|
||||
# ============================================================
|
||||
|
||||
SDK_PACKAGES = [
|
||||
{
|
||||
"language": "python",
|
||||
"name": "Python SDK",
|
||||
"version": "1.2.0",
|
||||
"description": "Official Python SDK for Qihu-AIFlow platform. Supports chat completions, knowledge base, agent execution, and file upload.",
|
||||
"install_cmd": "pip install qihu-aiflow",
|
||||
"download_url": "https://pypi.org/project/qihu-aiflow/",
|
||||
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-python",
|
||||
"min_version": "Python 3.8+",
|
||||
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "Webhook verification", "Async support"],
|
||||
"code_example": '''from qihu_aiflow import QihuClient
|
||||
|
||||
client = QihuClient(api_key="sk-your-api-key", base_url="http://210.16.161.218:18080")
|
||||
|
||||
# Chat completion
|
||||
response = client.chat.create(
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
# Streaming
|
||||
for chunk in client.chat.create(
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages=[{"role": "user", "content": "Tell me a story"}],
|
||||
stream=True
|
||||
):
|
||||
print(chunk.choices[0].delta.content, end="")''',
|
||||
},
|
||||
{
|
||||
"language": "javascript",
|
||||
"name": "JavaScript SDK",
|
||||
"version": "1.1.0",
|
||||
"description": "Official JavaScript/TypeScript SDK for browser and Node.js. Full TypeScript support with type definitions.",
|
||||
"install_cmd": "npm install @qihu/aiflow",
|
||||
"download_url": "https://www.npmjs.com/package/@qihu/aiflow",
|
||||
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-js",
|
||||
"min_version": "Node.js 16+ / Browser",
|
||||
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "WebSocket support", "TypeScript types"],
|
||||
"code_example": '''import { QihuClient } from "@qihu/aiflow";
|
||||
|
||||
const client = new QihuClient({
|
||||
apiKey: "sk-your-api-key",
|
||||
baseUrl: "http://210.16.161.218:18080",
|
||||
});
|
||||
|
||||
// Chat completion
|
||||
const response = await client.chat.create({
|
||||
model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});
|
||||
console.log(response.choices[0].message.content);
|
||||
|
||||
// Streaming
|
||||
const stream = await client.chat.create({
|
||||
model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages: [{ role: "user", content: "Tell me a story" }],
|
||||
stream: true,
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
process.stdout.write(chunk.choices[0].delta.content || "");
|
||||
}''',
|
||||
},
|
||||
{
|
||||
"language": "go",
|
||||
"name": "Go SDK",
|
||||
"version": "1.0.0",
|
||||
"description": "Official Go SDK for Qihu-AIFlow. Lightweight and fast, suitable for microservices and CLI tools.",
|
||||
"install_cmd": "go get github.com/qihuyun/qihu-aiflow-sdk-go",
|
||||
"download_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go/releases",
|
||||
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go",
|
||||
"min_version": "Go 1.19+",
|
||||
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "Context cancellation", "Retry logic", "Connection pooling"],
|
||||
"code_example": '''package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/qihuyun/qihu-aiflow-sdk-go/qihu"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := qihu.NewClient("sk-your-api-key", "http://210.16.161.218:18080")
|
||||
|
||||
resp, err := client.Chat.Create(context.Background(), &qihu.ChatRequest{
|
||||
Model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
Messages: []qihu.Message{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(resp.Choices[0].Message.Content)
|
||||
}''',
|
||||
},
|
||||
{
|
||||
"language": "curl",
|
||||
"name": "REST API (cURL)",
|
||||
"version": "1.0.0",
|
||||
"description": "Direct REST API access without SDK. Use any HTTP client to interact with the platform.",
|
||||
"install_cmd": "# No installation needed - use curl directly",
|
||||
"download_url": "https://docs.qihuyun.com/api-reference",
|
||||
"github_url": "",
|
||||
"min_version": "Any HTTP client",
|
||||
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "File upload", "Webhook management", "No dependencies"],
|
||||
"code_example": '''# Chat completion
|
||||
curl -X POST http://210.16.161.218:18080/app/api/chat/completions \\
|
||||
-H "Authorization: Bearer sk-your-api-key" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
|
||||
# List knowledge bases
|
||||
curl http://210.16.161.218:18080/app/api/knowledge-bases \\
|
||||
-H "Authorization: Bearer sk-your-api-key"''',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sdk/packages", summary="List SDK packages")
|
||||
async def list_sdk_packages(
|
||||
admin_user=Depends(get_current_admin),
|
||||
):
|
||||
"""List all available SDK packages with download info and code examples."""
|
||||
return {"code": 0, "data": {"packages": SDK_PACKAGES}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Interactive API Documentation
|
||||
# ============================================================
|
||||
|
||||
API_DOC_SECTIONS = [
|
||||
{
|
||||
"section": "Authentication",
|
||||
"icon": "lock",
|
||||
"description": "All API requests require authentication via Bearer token.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/auth/login",
|
||||
"title": "User Login",
|
||||
"description": "Authenticate and obtain an access token.",
|
||||
"params": [
|
||||
{"name": "username", "type": "string", "required": True, "desc": "Username"},
|
||||
{"name": "password", "type": "string", "required": True, "desc": "Password"},
|
||||
],
|
||||
"example_request": '{"username": "admin", "password": "admin123"}',
|
||||
"example_response": '{"code": 0, "data": {"access_token": "eyJ...", "token_type": "bearer", "user": {"id": "admin-001", "username": "admin"}}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "Chat & Completions",
|
||||
"icon": "chat",
|
||||
"description": "AI chat completion endpoints for conversational AI.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/chat/completions",
|
||||
"title": "Chat Completion",
|
||||
"description": "Create a chat completion with the specified model.",
|
||||
"params": [
|
||||
{"name": "model", "type": "string", "required": True, "desc": "Model ID (e.g. deepseek-ai/DeepSeek-V4-Flash)"},
|
||||
{"name": "messages", "type": "array", "required": True, "desc": "Array of message objects"},
|
||||
{"name": "stream", "type": "boolean", "required": False, "desc": "Enable streaming response"},
|
||||
{"name": "temperature", "type": "number", "required": False, "desc": "Sampling temperature (0-2)"},
|
||||
{"name": "max_tokens", "type": "integer", "required": False, "desc": "Maximum tokens to generate"},
|
||||
],
|
||||
"example_request": '{"model": "deepseek-ai/DeepSeek-V4-Flash", "messages": [{"role": "user", "content": "Hello"}]}',
|
||||
"example_response": '{"code": 0, "data": {"id": "chat-xxx", "choices": [{"message": {"role": "assistant", "content": "Hi there!"}}]}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "Knowledge Base",
|
||||
"icon": "book",
|
||||
"description": "Manage knowledge bases and documents for RAG.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/app/api/knowledge-bases",
|
||||
"title": "List Knowledge Bases",
|
||||
"description": "Get all knowledge bases for the current user.",
|
||||
"params": [],
|
||||
"example_request": "",
|
||||
"example_response": '{"code": 0, "data": [{"id": "kb-001", "name": "Product Docs", "document_count": 15}]}',
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/knowledge-bases/{kb_id}/documents",
|
||||
"title": "Upload Document",
|
||||
"description": "Upload a document to a knowledge base for processing.",
|
||||
"params": [
|
||||
{"name": "file", "type": "file", "required": True, "desc": "Document file (PDF, TXT, DOCX, MD)"},
|
||||
],
|
||||
"example_request": "multipart/form-data: file=@document.pdf",
|
||||
"example_response": '{"code": 0, "data": {"document_id": "doc-001", "status": "processing"}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "Agents",
|
||||
"icon": "robot",
|
||||
"description": "Agent management and execution endpoints.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/app/api/agents",
|
||||
"title": "List Agents",
|
||||
"description": "Get all available agents.",
|
||||
"params": [],
|
||||
"example_request": "",
|
||||
"example_response": '{"code": 0, "data": [{"id": "agent-001", "name": "Code Assistant", "type": "agent"}]}',
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/agents/{agent_id}/execute",
|
||||
"title": "Execute Agent",
|
||||
"description": "Execute an agent with a given input.",
|
||||
"params": [
|
||||
{"name": "input", "type": "string", "required": True, "desc": "Input text for the agent"},
|
||||
{"name": "context", "type": "object", "required": False, "desc": "Additional context"},
|
||||
],
|
||||
"example_request": '{"input": "Help me write a Python function"}',
|
||||
"example_response": '{"code": 0, "data": {"output": "Here is a Python function...", "tool_calls": []}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "Workflows",
|
||||
"icon": "workflow",
|
||||
"description": "DAG workflow orchestration endpoints.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/app/api/admin/workflows/templates",
|
||||
"title": "List Workflow Templates",
|
||||
"description": "Get predefined workflow templates.",
|
||||
"params": [],
|
||||
"example_request": "",
|
||||
"example_response": '{"code": 0, "data": [{"id": "tpl-001", "name": "Q&A Pipeline", "nodes": [...]}]}',
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/admin/workflows/{id}/execute",
|
||||
"title": "Execute Workflow",
|
||||
"description": "Execute a workflow with input data.",
|
||||
"params": [
|
||||
{"name": "input", "type": "string", "required": True, "desc": "Input for the workflow"},
|
||||
],
|
||||
"example_request": '{"input": "Summarize this article"}',
|
||||
"example_response": '{"code": 0, "data": {"execution_id": "exec-001", "status": "completed", "result": "..."}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "File Storage",
|
||||
"icon": "storage",
|
||||
"description": "File upload and storage management.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/files/upload",
|
||||
"title": "Upload File",
|
||||
"description": "Upload a file to storage (MinIO/OSS).",
|
||||
"params": [
|
||||
{"name": "file", "type": "file", "required": True, "desc": "File to upload"},
|
||||
{"name": "category", "type": "string", "required": False, "desc": "File category"},
|
||||
],
|
||||
"example_request": "multipart/form-data: file=@image.png",
|
||||
"example_response": '{"code": 0, "data": {"url": "https://...", "size": 102400, "file_type": "image"}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section": "Webhooks",
|
||||
"icon": "webhook",
|
||||
"description": "Webhook configuration and management.",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/app/api/admin/developer-tools/webhooks",
|
||||
"title": "List Webhooks",
|
||||
"description": "List all configured webhooks.",
|
||||
"params": [],
|
||||
"example_request": "",
|
||||
"example_response": '{"code": 0, "data": {"total": 2, "items": [{"id": "wh_xxx", "name": "My Webhook", "url": "https://...", "status": "active"}]}}',
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/app/api/admin/developer-tools/webhooks/",
|
||||
"title": "Create Webhook",
|
||||
"description": "Create a new webhook endpoint.",
|
||||
"params": [
|
||||
{"name": "name", "type": "string", "required": True, "desc": "Webhook name"},
|
||||
{"name": "url", "type": "string", "required": True, "desc": "Webhook URL"},
|
||||
{"name": "events", "type": "array", "required": True, "desc": "Events to subscribe"},
|
||||
],
|
||||
"example_request": '{"name": "My Webhook", "url": "https://example.com/webhook", "events": ["app.created"]}',
|
||||
"example_response": '{"code": 0, "data": {"id": "wh_xxx", "secret": "whsec_..."}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/api-docs", summary="Interactive API documentation")
|
||||
async def get_api_docs(
|
||||
admin_user=Depends(get_current_admin),
|
||||
):
|
||||
"""Get structured API documentation for the interactive docs viewer."""
|
||||
total_endpoints = sum(len(s["endpoints"]) for s in API_DOC_SECTIONS)
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"sections": API_DOC_SECTIONS,
|
||||
"total_sections": len(API_DOC_SECTIONS),
|
||||
"total_endpoints": total_endpoints,
|
||||
"base_url": "http://210.16.161.218:18080",
|
||||
"api_version": "v1",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Enhanced Usage Dashboard
|
||||
# ============================================================
|
||||
|
||||
@router.get("/usage/dashboard", summary="Enhanced usage dashboard")
|
||||
async def usage_dashboard(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Enhanced usage statistics dashboard with trend data."""
|
||||
now = cst_now()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# API call trends (daily)
|
||||
trend_sql = """SELECT DATE(created_at) as date, COUNT(*) as calls,
|
||||
SUM(CASE WHEN error_count > 0 THEN 1 ELSE 0 END) as errors,
|
||||
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
|
||||
FROM api_call_stats
|
||||
WHERE created_at >= :start
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date"""
|
||||
trend_rows = _execute_raw(db, trend_sql, {"start": start_date})
|
||||
|
||||
trend = []
|
||||
for r in trend_rows:
|
||||
trend.append({
|
||||
"date": r["date"].strftime("%Y-%m-%d") if r.get("date") else "",
|
||||
"calls": r.get("calls", 0),
|
||||
"errors": r.get("errors", 0),
|
||||
"avg_time": round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0,
|
||||
})
|
||||
|
||||
# Top endpoints by call count
|
||||
top_sql = """SELECT endpoint, method, call_count, error_count,
|
||||
last_called_at, total_response_time
|
||||
FROM api_call_stats
|
||||
ORDER BY call_count DESC
|
||||
LIMIT 10"""
|
||||
top_rows = _execute_raw(db, top_sql)
|
||||
|
||||
top_endpoints = []
|
||||
for r in top_rows:
|
||||
top_endpoints.append({
|
||||
"endpoint": r.get("endpoint", ""),
|
||||
"method": r.get("method", ""),
|
||||
"call_count": r.get("call_count", 0),
|
||||
"error_count": r.get("error_count", 0),
|
||||
"error_rate": round(r.get("error_count", 0) / max(r.get("call_count", 1), 1) * 100, 2) if r.get("call_count", 0) > 0 else 0,
|
||||
"avg_response_time": round(r.get("total_response_time", 0) / max(r.get("call_count", 1), 1), 2) if r.get("call_count", 0) > 0 else 0,
|
||||
"last_called_at": fmt_time(r.get("last_called_at")),
|
||||
})
|
||||
|
||||
# Error distribution by status code
|
||||
error_sql = """SELECT
|
||||
CASE
|
||||
WHEN error_count > 0 THEN 'has_errors'
|
||||
ELSE 'success'
|
||||
END as status_group,
|
||||
COUNT(*) as count,
|
||||
SUM(call_count) as total_calls
|
||||
FROM api_call_stats
|
||||
GROUP BY status_group"""
|
||||
error_rows = _execute_raw(db, error_sql)
|
||||
|
||||
error_distribution = {"success": 0, "has_errors": 0, "success_calls": 0, "error_calls": 0}
|
||||
for r in error_rows:
|
||||
if r["status_group"] == "success":
|
||||
error_distribution["success"] = r["count"]
|
||||
error_distribution["success_calls"] = r.get("total_calls", 0)
|
||||
else:
|
||||
error_distribution["has_errors"] = r["count"]
|
||||
error_distribution["error_calls"] = r.get("total_calls", 0)
|
||||
|
||||
# Response time distribution
|
||||
latency_sql = """SELECT
|
||||
CASE
|
||||
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 100 THEN 'fast'
|
||||
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 500 THEN 'normal'
|
||||
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 1000 THEN 'slow'
|
||||
ELSE 'very_slow'
|
||||
END as latency_bucket,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
|
||||
FROM api_call_stats
|
||||
WHERE total_response_time IS NOT NULL AND call_count > 0
|
||||
GROUP BY latency_bucket"""
|
||||
latency_rows = _execute_raw(db, latency_sql)
|
||||
|
||||
latency_distribution = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
|
||||
latency_avg = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
|
||||
for r in latency_rows:
|
||||
bucket = r["latency_bucket"]
|
||||
latency_distribution[bucket] = r["count"]
|
||||
latency_avg[bucket] = round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0
|
||||
|
||||
# API key usage distribution
|
||||
key_sql = """SELECT k.name, k.status, COUNT(s.id) as endpoint_count,
|
||||
COALESCE(SUM(s.call_count), 0) as total_calls,
|
||||
COALESCE(SUM(s.error_count), 0) as total_errors
|
||||
FROM api_keys k
|
||||
LEFT JOIN api_call_stats s ON s.endpoint LIKE CONCAT('%api-key%', k.id, '%')
|
||||
GROUP BY k.id, k.name, k.status
|
||||
ORDER BY total_calls DESC
|
||||
LIMIT 10"""
|
||||
key_rows = _execute_raw(db, key_sql)
|
||||
|
||||
key_usage = []
|
||||
for r in key_rows:
|
||||
key_usage.append({
|
||||
"name": r.get("name", "Unknown"),
|
||||
"status": r.get("status", "unknown"),
|
||||
"total_calls": r.get("total_calls", 0),
|
||||
"total_errors": r.get("total_errors", 0),
|
||||
})
|
||||
|
||||
# Summary
|
||||
total_calls = sum(t["calls"] for t in trend)
|
||||
total_errors = sum(t["errors"] for t in trend)
|
||||
avg_response = sum(t["avg_time"] for t in trend) / len(trend) if trend else 0
|
||||
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"summary": {
|
||||
"total_calls": total_calls,
|
||||
"total_errors": total_errors,
|
||||
"error_rate": error_rate,
|
||||
"avg_response_time": round(avg_response, 2),
|
||||
"active_api_keys": error_distribution["success"] + error_distribution["has_errors"],
|
||||
},
|
||||
"trend": trend,
|
||||
"top_endpoints": top_endpoints,
|
||||
"error_distribution": error_distribution,
|
||||
"latency_distribution": latency_distribution,
|
||||
"latency_avg": latency_avg,
|
||||
"key_usage": key_usage,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ 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, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin import asset_admin
|
||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||
|
||||
from app.database import SessionLocal
|
||||
@@ -420,10 +422,12 @@ app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["
|
||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
||||
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
|
||||
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"])
|
||||
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
|
||||
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge'));
|
||||
const AdminApps = lazy(() => import('@/pages/admin/Apps'));
|
||||
const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
||||
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
||||
const AdminDeveloperTools = lazy(() => import("@/pages/admin/DeveloperTools"));
|
||||
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
||||
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
||||
const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter'));
|
||||
@@ -132,6 +133,7 @@ const App: React.FC = () => {
|
||||
<Route path="asset-center" element={<AdminAssetCenter />} />
|
||||
<Route path="automation" element={<AdminAutomation />} />
|
||||
<Route path="developer" element={<AdminDeveloper />} />
|
||||
<Route path="developer-tools" element={<AdminDeveloperTools />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -59,6 +59,7 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/admin/asset-center', icon: 'database', label: '资产中心' },
|
||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
||||
{ path: "/admin/developer-tools", label: "开发者工具", icon: "🔧" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,3 +136,20 @@
|
||||
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.dev-table { font-size: 12px; }
|
||||
}
|
||||
|
||||
/* Phase 3.9: Developer Tools styles */
|
||||
.dev-method { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: monospace; }
|
||||
.method-get { background: rgba(82,196,26,0.2); color: #52c41a; }
|
||||
.method-post { background: rgba(22,119,255,0.2); color: #1677ff; }
|
||||
.method-put { background: rgba(250,173,20,0.2); color: #faad14; }
|
||||
.method-delete { background: rgba(255,77,79,0.2); color: #ff4d4f; }
|
||||
|
||||
.dev-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.dev-modal { background: #1a1a2e; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; overflow-y: auto; }
|
||||
.dev-modal h3 { margin: 0 0 16px; color: rgba(255,255,255,0.85); }
|
||||
.dev-form-group { margin-bottom: 14px; }
|
||||
.dev-form-group label { display: block; font-size: 13px; color: rgba(255,255,255,0.55); margin-bottom: 4px; }
|
||||
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
.dev-muted { color: rgba(255,255,255,0.35); font-size: 12px; }
|
||||
.dev-test-result { margin: 12px 0; padding: 12px; border-radius: 8px; font-size: 14px; }
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import './Developer.css';
|
||||
|
||||
const API_BASE = '/app/api';
|
||||
|
||||
interface Webhook {
|
||||
id: string; name: string; url: string; events: string[];
|
||||
has_secret: boolean; headers: Record<string, string>; status: string;
|
||||
last_triggered_at: string | null; last_status: string | null;
|
||||
total_deliveries: number; success_count: number; fail_count: number;
|
||||
success_rate: number; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
interface WebhookEvent {
|
||||
value: string; label: string; desc: string;
|
||||
}
|
||||
|
||||
interface WebhookDelivery {
|
||||
id: string; event: string; status: string; response_code: number | null;
|
||||
response_time_ms: number | null; attempts: number; error_message: string | null;
|
||||
payload: any; response_body: string; created_at: string;
|
||||
}
|
||||
|
||||
interface SDKPackage {
|
||||
language: string; name: string; version: string; description: string;
|
||||
install_cmd: string; download_url: string; github_url: string;
|
||||
min_version: string; features: string[]; code_example: string;
|
||||
}
|
||||
|
||||
interface ApiDocSection {
|
||||
section: string; icon: string; description: string;
|
||||
endpoints: Array<{
|
||||
method: string; path: string; title: string; description: string;
|
||||
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
|
||||
example_request: string; example_response: 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 AdminDeveloperTools() {
|
||||
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Webhooks state
|
||||
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
|
||||
const [showWebhookModal, setShowWebhookModal] = useState(false);
|
||||
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
||||
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
|
||||
// SDK state
|
||||
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
|
||||
const [selectedSdk, setSelectedSdk] = useState<string>('python');
|
||||
|
||||
// API Docs state
|
||||
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
|
||||
const loadWebhooks = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setWebhooks(res.data);
|
||||
} catch (e) { console.error('Webhooks error:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadWebhookEvents = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/webhooks/events');
|
||||
if (res.code === 0) setWebhookEvents(res.data.events);
|
||||
} catch (e) { console.error('Events error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
|
||||
if (res.code === 0) setDeliveries(res.data);
|
||||
} catch (e) { console.error('Deliveries error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadSdkPackages = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/sdk/packages');
|
||||
if (res.code === 0) setSdkPackages(res.data.packages);
|
||||
} catch (e) { console.error('SDK error:', e); }
|
||||
}, []);
|
||||
|
||||
const loadApiDocs = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/developer-tools/api-docs');
|
||||
if (res.code === 0) setDocSections(res.data.sections);
|
||||
} catch (e) { console.error('Docs error:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
|
||||
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
|
||||
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
|
||||
|
||||
const handleCreateWebhook = async () => {
|
||||
try {
|
||||
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
|
||||
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
|
||||
setShowWebhookModal(false);
|
||||
setWebhookForm({ name: '', url: '', events: [] });
|
||||
loadWebhooks();
|
||||
if (res.data?.secret) {
|
||||
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
|
||||
}
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: string) => {
|
||||
if (!confirm('Delete this webhook?')) return;
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
|
||||
loadWebhooks();
|
||||
setSelectedWebhook(null);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleToggleWebhook = async (wh: Webhook) => {
|
||||
const newStatus = wh.status === 'active' ? 'paused' : 'active';
|
||||
try {
|
||||
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleTestWebhook = async (id: string) => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
|
||||
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
|
||||
});
|
||||
setTestResult(res.data);
|
||||
loadWebhooks();
|
||||
} catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleViewDeliveries = (wh: Webhook) => {
|
||||
setSelectedWebhook(wh);
|
||||
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
|
||||
loadDeliveries(wh.id);
|
||||
};
|
||||
|
||||
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 === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
|
||||
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||
};
|
||||
|
||||
const methodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
|
||||
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Copied to clipboard');
|
||||
}).catch(() => alert('Copy failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-developer">
|
||||
<div className="dev-header">
|
||||
<h2>开发者工具</h2>
|
||||
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="dev-tabs">
|
||||
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
|
||||
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
|
||||
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="dev-tab-content">
|
||||
{loading && <div className="dev-loading">Loading...</div>}
|
||||
|
||||
{/* ===== Webhooks Tab ===== */}
|
||||
{activeTab === 'webhooks' && !loading && (
|
||||
<div>
|
||||
{selectedWebhook ? (
|
||||
/* Webhook Detail / Deliveries View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
|
||||
← Back to list
|
||||
</button>
|
||||
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
|
||||
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(255,255,255,0.05)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
|
||||
<strong>Test Result: </strong>
|
||||
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
|
||||
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
|
||||
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
|
||||
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>Event</th><th>Status</th><th>Response Code</th><th>Response Time</th><th>Attempts</th><th>Time</th><th>Error</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveries.items.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td className="mono-text">{d.event}</td>
|
||||
<td>{statusBadge(d.status)}</td>
|
||||
<td>{d.response_code || '-'}</td>
|
||||
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
|
||||
<td>{d.attempts}</td>
|
||||
<td>{fmtTime(d.created_at)}</td>
|
||||
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{deliveries.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
|
||||
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
|
||||
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Webhook List View */
|
||||
<div>
|
||||
<div className="dev-toolbar">
|
||||
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
|
||||
</div>
|
||||
<table className="dev-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>URL</th><th>Events</th><th>Status</th><th>Deliveries</th><th>Success Rate</th><th>Last Triggered</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webhooks.items.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
|
||||
<td>
|
||||
{w.events.length > 0 ? (
|
||||
<span>{w.events.length} events</span>
|
||||
) : <span className="dev-muted">All events</span>}
|
||||
</td>
|
||||
<td>{statusBadge(w.status)}</td>
|
||||
<td>{w.total_deliveries}</td>
|
||||
<td>
|
||||
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
|
||||
{w.success_rate}%
|
||||
</span>
|
||||
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
|
||||
</td>
|
||||
<td>{fmtTime(w.last_triggered_at)}</td>
|
||||
<td className="dev-actions">
|
||||
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>Test</button>
|
||||
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||||
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{webhooks.total > 20 && (
|
||||
<div className="dev-pagination">
|
||||
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
|
||||
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
|
||||
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supported Events Info */}
|
||||
{webhookEvents.length > 0 && (
|
||||
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 12px', color: 'rgba(255,255,255,0.85)' }}>Supported Events</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(255,255,255,0.05)', borderRadius: '6px' }}>
|
||||
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
|
||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.45)', marginTop: '2px' }}>{ev.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Webhook Modal */}
|
||||
{showWebhookModal && (
|
||||
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
|
||||
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>New Webhook</h3>
|
||||
<div className="dev-form-group">
|
||||
<label>Name</label>
|
||||
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>URL</label>
|
||||
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label>Events (leave empty for all)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||||
{webhookEvents.map(ev => (
|
||||
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(255,255,255,0.05)', borderRadius: '6px', fontSize: '13px' }}>
|
||||
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
|
||||
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
|
||||
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
|
||||
}} />
|
||||
{ev.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-modal-actions">
|
||||
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>Cancel</button>
|
||||
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== SDK Download Center Tab ===== */}
|
||||
{activeTab === 'sdk' && !loading && (
|
||||
<div>
|
||||
{/* Language Selector */}
|
||||
<div className="dev-toolbar">
|
||||
{sdkPackages.map(pkg => (
|
||||
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
|
||||
{pkg.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
|
||||
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||
{/* Package Info */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
|
||||
<p style={{ margin: '0', color: 'rgba(255,255,255,0.55)' }}>{pkg.description}</p>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '13px', color: 'rgba(255,255,255,0.4)' }}>Requires: {pkg.min_version}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
|
||||
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install Command */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '4px', display: 'block' }}>Installation</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
{pkg.install_cmd}
|
||||
</code>
|
||||
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '8px', display: 'block' }}>Features</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{pkg.features.map(f => (
|
||||
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Example */}
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '4px', display: 'block' }}>Quick Start Example</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
|
||||
<code>{pkg.code_example}</code>
|
||||
</pre>
|
||||
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sdkPackages.length === 0 && <div className="dev-empty">Loading SDK packages...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Interactive API Docs Tab ===== */}
|
||||
{activeTab === 'docs' && !loading && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
|
||||
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
|
||||
<code style={{ marginLeft: '8px', color: 'rgba(255,255,255,0.8)' }}>http://210.16.161.218:18080</code>
|
||||
<span style={{ marginLeft: '16px', color: 'rgba(255,255,255,0.4)' }}>API Version: v1</span>
|
||||
</div>
|
||||
|
||||
{docSections.map(section => {
|
||||
const isExpanded = expandedSection === section.section;
|
||||
return (
|
||||
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
|
||||
style={{ padding: '14px 16px', background: 'rgba(255,255,255,0.05)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '18px' }}>
|
||||
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
|
||||
</span>
|
||||
<strong style={{ color: 'rgba(255,255,255,0.85)' }}>{section.section}</strong>
|
||||
<span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
|
||||
</div>
|
||||
<span style={{ color: 'rgba(255,255,255,0.4)' }}>{isExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
{isExpanded && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
|
||||
<p style={{ padding: '8px 16px', margin: '0', color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>{section.description}</p>
|
||||
{section.endpoints.map(ep => {
|
||||
const epKey = `${section.section}-${ep.path}`;
|
||||
const isEpExpanded = expandedEndpoint === epKey;
|
||||
return (
|
||||
<div key={epKey} style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div
|
||||
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
|
||||
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
{methodBadge(ep.method)}
|
||||
<code style={{ color: 'rgba(255,255,255,0.7)', fontSize: '13px' }}>{ep.path}</code>
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>{ep.title}</span>
|
||||
<span style={{ marginLeft: 'auto', color: 'rgba(255,255,255,0.3)' }}>{isEpExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{isEpExpanded && (
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
<p style={{ color: 'rgba(255,255,255,0.55)', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
|
||||
|
||||
{/* Parameters */}
|
||||
{ep.params.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '6px' }}>Parameters</label>
|
||||
<table className="dev-table" style={{ fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.params.map(p => (
|
||||
<tr key={p.name}>
|
||||
<td className="mono-text">{p.name}</td>
|
||||
<td>{p.type}</td>
|
||||
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>Yes</span> : <span style={{ color: 'rgba(255,255,255,0.4)' }}>No</span>}</td>
|
||||
<td>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example Request */}
|
||||
{ep.example_request && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '4px' }}>Example Request</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_request}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example Response */}
|
||||
{ep.example_response && (
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '4px' }}>Example Response</label>
|
||||
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||
<code>{ep.example_response}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{docSections.length === 0 && <div className="dev-empty">Loading API documentation...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user