Phase G2-1~G2-6: Code Execution Worker - Docker sandbox deployment, backend API (code_exec_admin.py), frontend CodeExecTab, security hardening (read-only FS, non-root, capability dropping, RLIMIT_AS)

This commit is contained in:
qyyfw committed 2026-07-06 14:10:41 +08:00
1 parent 909c013e2d
commit 9b3e15ace6
9 files changed
+1709 -447

No files matched your search

@@ -0,0 +1,369 @@
"""
Code Execution Admin API
Manages code execution sandbox containers, task submission, and result retrieval.
Endpoints:
GET /stats - Execution statistics
GET /runtimes - Available runtime info from sandbox
POST /execute - Submit code for execution
GET /tasks - List execution tasks (with filters)
GET /tasks/{id} - Get specific task result
DELETE /tasks/{id} - Cancel/delete task
"""
import logging
import uuid
import json
import time
from datetime import datetime, timezone, timedelta
from typing import Optional, List
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import get_db, SessionLocal
from ...auth import get_current_admin
logger = logging.getLogger(__name__)
router = APIRouter()
CST = timezone(timedelta(hours=8))
# Code execution sandbox configuration
# The executor container is on the same Docker network, accessible by container name
EXECUTOR_URL = "http://qihuaiflow-executor:8000"
EXECUTOR_TIMEOUT = 120 # max execution timeout
# Supported languages
SUPPORTED_LANGUAGES = ["python", "javascript"]
def _execute_raw(db: Session, sql, params=None):
"""Execute raw SQL and return results."""
result = db.execute(text(sql), params or {})
if sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith("WITH"):
rows = result.fetchall()
if rows:
return [dict(r._mapping) for r in rows]
return []
db.commit()
return result
def _format_cst_time(dt):
"""Format datetime to CST string."""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def _now_cst():
"""Get current time as CST-aware datetime."""
return datetime.now(CST)
# ===== Pydantic Models =====
class ExecuteRequest(BaseModel):
language: str = "python"
code: str
stdin: str = ""
timeout: int = Field(default=30, ge=1, le=120)
class ExecuteResponse(BaseModel):
task_id: str
success: bool
stdout: str = ""
stderr: str = ""
exit_code: int = 0
execution_time_ms: float = 0
error: Optional[str] = None
truncated: bool = False
created_at: str = ""
class TaskFilter(BaseModel):
language: Optional[str] = None
status: Optional[str] = None
page: int = 1
page_size: int = 20
# ===== API Endpoints =====
@router.get("/stats")
async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Get code execution statistics."""
try:
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
today = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"]
success_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
failed_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code != 0 OR error IS NOT NULL")[0]["cnt"]
timeout_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE error LIKE '%timeout%'")[0]["cnt"]
by_language = _execute_raw(db,
"SELECT language, COUNT(*) as cnt FROM code_exec_tasks GROUP BY language")
avg_time = _execute_raw(db,
"SELECT AVG(execution_time_ms) as avg_ms FROM code_exec_tasks WHERE execution_time_ms > 0")[0]
total_avg = round(avg_time.get("avg_ms", 0) or 0, 2)
return {
"code": 0,
"data": {
"total": total,
"today": today,
"success": success_count,
"failed": failed_count,
"timeout": timeout_count,
"avg_execution_time_ms": total_avg,
"by_language": {r["language"]: r["cnt"] for r in by_language} if by_language else {},
}
}
except Exception as e:
# Table might not exist yet
return {
"code": 0,
"data": {
"total": 0, "today": 0, "success": 0, "failed": 0, "timeout": 0,
"avg_execution_time_ms": 0, "by_language": {}
}
}
@router.get("/runtimes")
async def get_runtimes(_=Depends(get_current_admin)):
"""Get sandbox runtime info."""
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{EXECUTOR_URL}/info")
if resp.status_code == 200:
return {"code": 0, "data": resp.json()}
except Exception as e:
pass
return {
"code": 0,
"data": {
"python": "Python 3.11 (sandbox)",
"nodejs": "Node.js (sandbox)",
"workspace": "/workspace",
"max_code_length": 51200,
"max_timeout": 120,
}
}
@router.post("/execute", response_model=ExecuteResponse)
async def execute_code(req: ExecuteRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Submit code for execution."""
if req.language not in SUPPORTED_LANGUAGES:
raise HTTPException(status_code=400, detail=f"Unsupported language: {req.language}")
task_id = str(uuid.uuid4())[:8]
now = _now_cst()
# Create task record
_execute_raw(db, """
INSERT INTO code_exec_tasks (id, language, code_preview, status, timeout_sec, created_at)
VALUES (:id, :lang, :preview, 'running', :timeout, :now)
""", {
"id": task_id,
"lang": req.language,
"preview": req.code[:200],
"timeout": req.timeout,
"now": now,
})
# Execute via sandbox
result = {
"success": False,
"stdout": "",
"stderr": "",
"exit_code": -1,
"execution_time_ms": 0,
"error": None,
}
try:
async with httpx.AsyncClient(timeout=req.timeout + 10) as client:
resp = await client.post(
f"{EXECUTOR_URL}/execute",
json={
"language": req.language,
"code": req.code,
"stdin": req.stdin,
"timeout": req.timeout,
},
)
if resp.status_code == 200:
data = resp.json()
result = {
"success": data.get("success", False),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1),
"execution_time_ms": data.get("execution_time_ms", 0),
"error": data.get("error"),
"truncated": data.get("truncated", False),
}
else:
result["error"] = f"Sandbox returned {resp.status_code}: {resp.text[:200]}"
except httpx.TimeoutException:
result["error"] = f"Execution timed out after {req.timeout}s"
except httpx.ConnectError:
result["error"] = "Execution sandbox is not available"
except Exception as e:
result["error"] = str(e)[:500]
# Determine status
if result["error"]:
status = "failed"
elif result["exit_code"] != 0:
status = "error"
else:
status = "completed"
# Update task record
now2 = _now_cst()
_execute_raw(db, """
UPDATE code_exec_tasks SET
status = :status, exit_code = :exit_code, execution_time_ms = :time_ms,
stdout = :stdout, stderr = :stderr, error_msg = :error,
truncated = :truncated, completed_at = :now
WHERE id = :id
""", {
"status": status,
"exit_code": result["exit_code"],
"time_ms": result["execution_time_ms"],
"stdout": result["stdout"][:50000] if result.get("stdout") else "",
"stderr": result["stderr"][:50000] if result.get("stderr") else "",
"error": result["error"][:2000] if result.get("error") else None,
"truncated": 1 if result.get("truncated") else 0,
"now": now2,
"id": task_id,
})
return ExecuteResponse(
task_id=task_id,
success=result["success"],
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
exit_code=result["exit_code"],
execution_time_ms=result["execution_time_ms"],
error=result.get("error"),
truncated=result.get("truncated", False),
created_at=_format_cst_time(now),
)
@router.get("/tasks")
async def list_tasks(
language: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_=Depends(get_current_admin),
):
"""List execution tasks with filters."""
where_clauses = []
params = {}
if language:
where_clauses.append("language = :lang")
params["lang"] = language
if status:
where_clauses.append("status = :status")
params["status"] = status
where_sql = ""
if where_clauses:
where_sql = "WHERE " + " AND ".join(where_clauses)
# Count total
count_result = _execute_raw(db, f"SELECT COUNT(*) as cnt FROM code_exec_tasks {where_sql}", params)
total = count_result[0]["cnt"] if count_result else 0
# Fetch page
offset = (page - 1) * page_size
tasks = _execute_raw(db, f"""
SELECT id, language, code_preview, status, exit_code, execution_time_ms,
error_msg, truncated, created_at, completed_at
FROM code_exec_tasks {where_sql}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""", {**params, "limit": page_size, "offset": offset})
items = []
for t in (tasks or []):
items.append({
"id": t["id"],
"language": t["language"],
"code_preview": t.get("code_preview", ""),
"status": t["status"],
"exit_code": t.get("exit_code"),
"execution_time_ms": t.get("execution_time_ms"),
"error_msg": t.get("error_msg"),
"truncated": bool(t.get("truncated", 0)),
"created_at": _format_cst_time(t.get("created_at")),
"completed_at": _format_cst_time(t.get("completed_at")),
})
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": items,
}
}
@router.get("/tasks/{task_id}")
async def get_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Get a specific task with full output."""
results = _execute_raw(db, """
SELECT id, language, code_preview, status, exit_code, execution_time_ms,
stdout, stderr, error_msg, truncated, created_at, completed_at
FROM code_exec_tasks WHERE id = :id
""", {"id": task_id})
if not results:
raise HTTPException(status_code=404, detail="Task not found")
t = results[0]
return {
"code": 0,
"data": {
"id": t["id"],
"language": t["language"],
"code_preview": t.get("code_preview", ""),
"status": t["status"],
"exit_code": t.get("exit_code"),
"execution_time_ms": t.get("execution_time_ms"),
"stdout": t.get("stdout", ""),
"stderr": t.get("stderr", ""),
"error_msg": t.get("error_msg"),
"truncated": bool(t.get("truncated", 0)),
"created_at": _format_cst_time(t.get("created_at")),
"completed_at": _format_cst_time(t.get("completed_at")),
}
}
@router.delete("/tasks/{task_id}")
async def delete_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Delete a task record."""
result = _execute_raw(db, "DELETE FROM code_exec_tasks WHERE id = :id", {"id": task_id})
return {"code": 0, "msg": "deleted"}
+2 -1
View File
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution from app.api 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 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, code_exec_admin
from app.api.admin.developer_tools_admin import router as developer_tools_router from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin from app.api.admin import asset_admin
@@ -431,6 +431,7 @@ app.include_router(developer_admin.router, prefix="/app/api/admin/developer", ta
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) 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"]) app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
+42
View File
@@ -0,0 +1,42 @@
FROM python:3.11-slim
LABEL maintainer="qihuaiflow"
LABEL description="Code Execution Sandbox - isolated code runner for Python and JavaScript"
# Switch to Aliyun mirrors for faster downloads in China
RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's|http://deb.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources
# Install Node.js from Debian repos + essential tools
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
nodejs \
npm \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/nodejs /usr/bin/node 2>/dev/null || true
# Install Python packages with Aliyun mirror
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
fastapi==0.104.1 \
uvicorn==0.24.0 \
pydantic==2.5.2
# Create non-root user for security
RUN useradd -m -s /bin/bash executor \
&& mkdir -p /workspace /app \
&& chown -R executor:executor /workspace /app
# Copy executor server
COPY executor_server.py /app/executor_server.py
RUN chown executor:executor /app/executor_server.py
WORKDIR /app
USER executor
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "executor_server:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
+256
View File
@@ -0,0 +1,256 @@
"""
Code Execution Sandbox Server
Runs inside a Docker container, accepts code execution requests.
Supports: Python, JavaScript
"""
import asyncio
import subprocess
import tempfile
import os
import time
import resource
import json
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Code Execution Sandbox", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Use /tmp for temp files (writable by executor user)
# /workspace is a tmpfs mount owned by root
WORKSPACE = Path("/tmp")
WORKSPACE.mkdir(exist_ok=True)
# Max code length: 50KB
MAX_CODE_LENGTH = 50 * 1024
# Default timeout: 30s, max: 120s
DEFAULT_TIMEOUT = 30
MAX_TIMEOUT = 120
class ExecuteRequest(BaseModel):
language: str = Field(..., description="Programming language: python, javascript")
code: str = Field(..., description="Source code to execute")
stdin: str = Field(default="", description="Standard input for the program")
timeout: int = Field(default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Execution timeout in seconds")
class ExecuteResponse(BaseModel):
success: bool
stdout: str = ""
stderr: str = ""
exit_code: int = 0
execution_time_ms: float = 0
error: Optional[str] = None
truncated: bool = False
@app.get("/health")
async def health():
return {"status": "healthy", "languages": ["python", "javascript"]}
@app.get("/info")
async def info():
"""Return sandbox runtime info."""
py_ver = subprocess.run(["python3", "--version"], capture_output=True, text=True).stdout.strip()
try:
node_ver = subprocess.run(["node", "--version"], capture_output=True, text=True).stdout.strip()
except Exception:
node_ver = "not installed"
return {
"python": py_ver,
"nodejs": node_ver,
"workspace": str(WORKSPACE),
"max_code_length": MAX_CODE_LENGTH,
"max_timeout": MAX_TIMEOUT,
}
@app.post("/execute", response_model=ExecuteResponse)
async def execute_code(req: ExecuteRequest):
"""Execute code in a sandboxed subprocess."""
language = req.language.lower().strip()
code = req.code
stdin_input = req.stdin
timeout = min(req.timeout, MAX_TIMEOUT)
# Validate language
if language not in ("python", "py", "python3", "javascript", "js", "node"):
raise HTTPException(status_code=400, detail=f"Unsupported language: {language}. Supported: python, javascript")
# Normalize language
if language in ("py", "python3"):
language = "python"
if language in ("js", "node"):
language = "javascript"
# Validate code length
if len(code.encode("utf-8")) > MAX_CODE_LENGTH:
raise HTTPException(status_code=400, detail=f"Code too long. Max {MAX_CODE_LENGTH // 1024}KB")
start_time = time.time()
try:
if language == "python":
result = await _run_python(code, stdin_input, timeout)
elif language == "javascript":
result = await _run_javascript(code, stdin_input, timeout)
else:
raise HTTPException(status_code=400, detail=f"Unsupported language: {language}")
except asyncio.TimeoutError:
execution_time = (time.time() - start_time) * 1000
return ExecuteResponse(
success=False,
stdout="",
stderr="",
exit_code=-1,
execution_time_ms=execution_time,
error=f"Execution timed out after {timeout}s",
)
execution_time_ms = (time.time() - start_time) * 1000
result["execution_time_ms"] = round(execution_time_ms, 2)
return result
async def _run_python(code: str, stdin_input: str, timeout: int) -> dict:
"""Execute Python code."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", dir=WORKSPACE, delete=False, encoding="utf-8"
) as f:
f.write(code)
script_path = f.name
try:
# Use resource limits
def set_limits():
# Memory limit: 256MB
resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024))
# CPU time limit: timeout + 5s grace
resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5))
proc = await asyncio.create_subprocess_exec(
"python3",
"-u", # unbuffered
script_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=set_limits,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None),
timeout=timeout,
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
stdout_str = stdout.decode("utf-8", errors="replace")
stderr_str = stderr.decode("utf-8", errors="replace")
# Truncate output to 500KB
truncated = False
if len(stdout_str.encode("utf-8")) > 500 * 1024:
stdout_str = stdout_str[:500 * 1024]
truncated = True
if len(stderr_str.encode("utf-8")) > 500 * 1024:
stderr_str = stderr_str[:500 * 1024]
truncated = True
return {
"success": proc.returncode == 0,
"stdout": stdout_str,
"stderr": stderr_str,
"exit_code": proc.returncode or 0,
"error": None,
"truncated": truncated,
}
finally:
# Cleanup temp file
try:
os.unlink(script_path)
except OSError:
pass
async def _run_javascript(code: str, stdin_input: str, timeout: int) -> dict:
"""Execute JavaScript code via Node.js."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".js", dir=WORKSPACE, delete=False, encoding="utf-8"
) as f:
f.write(code)
script_path = f.name
try:
def set_limits():
# Only limit CPU time for JS — V8 needs large virtual address space
# for CodeRange; rely on Docker --memory for actual RSS limit.
resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5))
# Use Node.js to run the script with tight heap limits
env = os.environ.copy()
node_flags = "--max-old-space-size=32 --max-semi-space-size=8"
env["NODE_OPTIONS"] = node_flags
proc = await asyncio.create_subprocess_exec(
"node",
"--max-old-space-size=32",
"--max-semi-space-size=8",
script_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
preexec_fn=set_limits,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None),
timeout=timeout,
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
stdout_str = stdout.decode("utf-8", errors="replace")
stderr_str = stderr.decode("utf-8", errors="replace")
truncated = False
if len(stdout_str.encode("utf-8")) > 500 * 1024:
stdout_str = stdout_str[:500 * 1024]
truncated = True
if len(stderr_str.encode("utf-8")) > 500 * 1024:
stderr_str = stderr_str[:500 * 1024]
truncated = True
return {
"success": proc.returncode == 0,
"stdout": stdout_str,
"stderr": stderr_str,
"exit_code": proc.returncode or 0,
"error": None,
"truncated": truncated,
}
finally:
try:
os.unlink(script_path)
except OSError:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+128 -145
View File
@@ -1,145 +1,128 @@
import React, { useState, useEffect, Suspense, lazy } from 'react'; import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types'; import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts - eagerly loaded (needed immediately)
import FrontLayout from '@/components/layout/FrontLayout'; // Layouts
import AdminLayout from '@/components/layout/AdminLayout'; import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// Front Pages - lazy loaded
const LandingPage = lazy(() => import('@/pages/front/LandingPage')); // Front Pages
const LoginPage = lazy(() => import('@/pages/front/LoginPage')); import LandingPage from '@/pages/front/LandingPage';
const ChatPage = lazy(() => import('@/pages/front/ChatPage')); import LoginPage from '@/pages/front/LoginPage';
const ChannelPage = lazy(() => import('@/pages/front/ChannelPage')); import ChatPage from '@/pages/front/ChatPage';
const KnowledgePage = lazy(() => import('@/pages/front/KnowledgePage')); import ChannelPage from '@/pages/front/ChannelPage';
const MarketPage = lazy(() => import('@/pages/front/MarketPage')); import KnowledgePage from '@/pages/front/KnowledgePage';
const TokenDashboardPage = lazy(() => import('@/pages/front/TokenDashboardPage')); import MarketPage from '@/pages/front/MarketPage';
const PricingPage = lazy(() => import('@/pages/front/PricingPage')); import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
const ProfilePage = lazy(() => import('@/pages/front/ProfilePage')); import PricingPage from '@/pages/front/PricingPage';
const NotificationPage = lazy(() => import('@/pages/front/NotificationPage')); import ProfilePage from '@/pages/front/ProfilePage';
const AssetsPage = lazy(() => import('@/pages/front/AssetsPage')); import NotificationPage from '@/pages/front/NotificationPage';
const NovelPreviewPage = lazy(() => import('@/pages/front/NovelPreviewPage')); import AssetsPage from '@/pages/front/AssetsPage';
const EvolutionDashboard = lazy(() => import('@/pages/front/EvolutionDashboard')); import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
// Admin Pages - lazy loaded
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')); // Admin Pages
const AdminUsers = lazy(() => import('@/pages/admin/Users')); import AdminDashboard from '@/pages/admin/Dashboard';
const AdminModels = lazy(() => import('@/pages/admin/Models')); import AdminUsers from '@/pages/admin/Users';
const AdminSiteSettings = lazy(() => import('@/pages/admin/SiteSettings')); import AdminModels from '@/pages/admin/Models';
const AdminPayment = lazy(() => import('@/pages/admin/Payment')); import AdminSiteSettings from '@/pages/admin/SiteSettings';
const AdminServices = lazy(() => import('@/pages/admin/Services')); import AdminPayment from '@/pages/admin/Payment';
const AdminMembership = lazy(() => import('@/pages/admin/Membership')); import AdminServices from '@/pages/admin/Services';
const AdminAds = lazy(() => import('@/pages/admin/Ads')); import AdminMembership from '@/pages/admin/Membership';
const AdminMarket = lazy(() => import('@/pages/admin/Market')); import AdminAds from '@/pages/admin/Ads';
const AdminLogs = lazy(() => import('@/pages/admin/Logs')); import AdminMarket from '@/pages/admin/Market';
const AdminSkills = lazy(() => import('@/pages/admin/Skills')); import AdminLogs from '@/pages/admin/Logs';
const AdminAgents = lazy(() => import('@/pages/admin/Agents')); import AdminSkills from '@/pages/admin/Skills'
const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge')); import AdminAgents from '@/pages/admin/Agents';
// Phase C2: New orphan page routes import AdminCodeExec from '@/pages/admin/CodeExec';;
const AdminApps = lazy(() => import('@/pages/admin/Apps'));
const AdminAutomation = lazy(() => import('@/pages/admin/Automation')); const App: React.FC = () => {
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer')); const [user, setUser] = useState<User | null>(null);
const AdminDeveloperTools = lazy(() => import("@/pages/admin/DeveloperTools")); const [loading, setLoading] = useState(true);
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
const AdminStorage = lazy(() => import('@/pages/admin/Storage')); // 初始化:从localStorage恢复登录状态
const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter')); useEffect(() => {
const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow')); const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
// Loading fallback if (token && cachedUser) {
const PageLoading: React.FC = () => ( try {
<div style={{ setUser(JSON.parse(cachedUser));
display: 'flex', alignItems: 'center', justifyContent: 'center', } catch {
padding: '60px 20px', color: 'var(--color-text-tertiary)', fontSize: '14px', localStorage.removeItem('aiflow_token');
gap: '12px', localStorage.removeItem('aiflow_user');
}}> }
<div className="audit-spinner" /> }
<span>加载中...</span> setLoading(false);
</div> }, []);
);
const login = (u: User) => {
const App: React.FC = () => { setUser(u);
const [user, setUser] = useState<User | null>(null); localStorage.setItem('aiflow_user', JSON.stringify(u));
const [loading, setLoading] = useState(true); };
const logout = () => {
useEffect(() => { setUser(null);
const token = localStorage.getItem('aiflow_token'); localStorage.removeItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user'); localStorage.removeItem('aiflow_user');
if (token && cachedUser) { };
try { setUser(JSON.parse(cachedUser)); } catch { const isAdmin = user?.role === 'admin';
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user'); if (loading) {
} return <div className="loading-screen">加载中...</div>;
} }
setLoading(false);
}, []); return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
const login = (u: User) => { setUser(u); localStorage.setItem('aiflow_user', JSON.stringify(u)); }; <Routes>
const logout = () => { setUser(null); localStorage.removeItem('aiflow_token'); localStorage.removeItem('aiflow_user'); }; {/* Login */}
const isAdmin = user?.role === 'admin'; <Route path="/login" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
if (loading) return ( } />
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', {/* Root - Landing for guests, redirect for authenticated users */}
height: '100vh', color: '#6C63FF', flexDirection: 'column', gap: 16, <Route path="/" element={
}}> user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
<div className="audit-spinner" style={{ width: 40, height: 40 }} /> } />
<span style={{ fontSize: 14, color: '#9CA3AF' }}>加载中...</span>
</div> {/* Front Routes (authenticated only) */}
); <Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
return ( <Route path="chat/:id" element={<ChatPage />} />
<AuthContext.Provider value={{ user, login, logout, isAdmin }}> <Route path="channels" element={<ChannelPage />} />
<Suspense fallback={<PageLoading />}> <Route path="knowledge" element={<KnowledgePage />} />
<Routes> <Route path="market" element={<MarketPage />} />
<Route path="/login" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />} /> <Route path="token" element={<TokenDashboardPage />} />
<Route path="/" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />} /> <Route path="pricing" element={<PricingPage />} />
<Route path="/" element={<FrontLayout />}> <Route path="profile" element={<ProfilePage />} />
<Route path="chat" element={<ChatPage />} /> <Route path="notifications" element={<NotificationPage />} />
<Route path="chat/:id" element={<ChatPage />} /> <Route path="assets" element={<AssetsPage />} />
<Route path="channels" element={<ChannelPage />} /> <Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="knowledge" element={<KnowledgePage />} /> <Route path="evolution" element={<EvolutionDashboard />} />
<Route path="market" element={<MarketPage />} /> </Route>
<Route path="token" element={<TokenDashboardPage />} />
<Route path="pricing" element={<PricingPage />} /> {/* Admin Routes */}
<Route path="profile" element={<ProfilePage />} /> <Route path="/admin" element={<AdminLayout />}>
<Route path="notifications" element={<NotificationPage />} /> <Route index element={<AdminDashboard />} />
<Route path="assets" element={<AssetsPage />} /> <Route path="users" element={<AdminUsers />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} /> <Route path="models" element={<AdminModels />} />
<Route path="evolution" element={<EvolutionDashboard />} /> <Route path="settings" element={<AdminSiteSettings />} />
</Route> <Route path="payment" element={<AdminPayment />} />
<Route path="/admin" element={<AdminLayout />}> <Route path="services" element={<AdminServices />} />
<Route index element={<AdminDashboard />} /> <Route path="membership" element={<AdminMembership />} />
{/* 用户体系 */} <Route path="ads" element={<AdminAds />} />
<Route path="users" element={<AdminUsers />} /> <Route path="market" element={<AdminMarket />} />
<Route path="membership" element={<AdminMembership />} /> <Route path="logs" element={<AdminLogs />} />
{/* AI能力 */}
<Route path="models" element={<AdminModels />} /> <Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} /> <Route path="agents" element={<AdminAgents />} />
<Route path="workflow" element={<AdminWorkflow />} /> <Route path="code-exec" element={<AdminCodeExec />} />
<Route path="skills" element={<AdminSkills />} /> </Route>
<Route path="knowledge" element={<AdminKnowledge />} />
<Route path="mcp" element={<AdminMcp />} /> <Route path="*" element={<Navigate to="/" />} />
{/* 业务运营 */} </Routes>
<Route path="market" element={<AdminMarket />} /> </AuthContext.Provider>
<Route path="services" element={<AdminServices />} /> );
<Route path="payment" element={<AdminPayment />} /> };
<Route path="apps" element={<AdminApps />} />
<Route path="ads" element={<AdminAds />} /> export default App;
{/* 系统设置 */}
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="storage" element={<AdminStorage />} />
<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>
</Suspense>
</AuthContext.Provider>
);
};
export default App;
+13
View File
@@ -650,6 +650,19 @@ export const adminAPI = {
seedMcpTools: () => seedMcpTools: () =>
post<any>('/admin/mcp-market/seed'), post<any>('/admin/mcp-market/seed'),
// Code Execution Sandbox (Phase G2)
getCodeExecStats: () => get<any>('/admin/code-exec/stats'),
getCodeExecRuntimes: () => get<any>('/admin/code-exec/runtimes'),
executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) =>
post<any>('/admin/code-exec/execute', data),
getCodeExecTasks: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`);
},
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
}; };
// ===== AI自进化API ===== // ===== AI自进化API =====
export const evolutionAPI = { export const evolutionAPI = {
+129 -301
View File
@@ -1,301 +1,129 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css'; import './AdminLayout.css';
interface NavGroup { const adminNavItems = [
label: string; { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
items: NavItem[]; { path: '/admin/users', icon: 'users', label: '用户管理' },
} { path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
interface NavItem { { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
path: string; { path: '/admin/services', icon: 'services', label: '增值服务管理' },
icon: string; { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
label: string; { path: '/admin/ads', icon: 'ads', label: '广告位管理' },
exact?: boolean; { path: '/admin/market', icon: 'market', label: '应用市场审核' },
} { path: '/admin/logs', icon: 'logs', label: '日志与风控' },
const navGroups: NavGroup[] = [ { path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
{ { path: '/admin/skills', icon: 'skills', label: '技能管理' },
label: '平台驾驶舱', { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },];
items: [
{ path: '/admin', icon: 'dashboard', label: '平台概览', exact: true }, const adminIconPaths: Record<string, JSX.Element> = {
], dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
}, users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
{ models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
label: '用户体系', settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
items: [ payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
{ path: '/admin/users', icon: 'users', label: '用户管理' }, services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
{ path: '/admin/membership', icon: 'membership', label: '会员体系' }, membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
], ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
}, market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
{ logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
label: 'AI能力',
items: [ skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
{ path: '/admin/models', icon: 'models', label: '模型控制台' }, code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,};
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, const AdminLayout: React.FC = () => {
{ path: '/admin/skills', icon: 'skills', label: '技能管理' }, const { user, logout } = useAuth();
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, const navigate = useNavigate();
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' }, const [collapsed, setCollapsed] = useState(false);
],
}, if (!user || user.role !== 'admin') {
{ navigate('/login');
label: '业务运营', return null;
items: [ }
{ path: '/admin/market', icon: 'market', label: '应用市场' },
{ path: '/admin/services', icon: 'services', label: '增值服务' }, return (
{ path: '/admin/payment', icon: 'payment', label: '支付管理' }, <div className="admin-layout">
{ path: '/admin/apps', icon: 'apps', label: 'AI应用' }, {/* Admin Sidebar */}
{ path: '/admin/ads', icon: 'ads', label: '广告管理' }, <aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
], <div className="admin-sidebar-header">
}, <div className="sidebar-logo">
{ <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
label: '系统设置', <rect width="32" height="32" rx="8" fill="#1A1A2E"/>
items: [ <path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
{ path: '/admin/settings', icon: 'settings', label: '站点设置' }, <path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
{ path: '/admin/logs', icon: 'logs', label: '日志管理' }, </svg>
{ path: '/admin/storage', icon: 'storage', label: '存储管理' }, {!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
{ path: '/admin/asset-center', icon: 'database', label: '资产中心' }, </div>
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' }, <button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' }, <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{ path: "/admin/developer-tools", label: "开发者工具", icon: "🔧" }, <line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
], </svg>
}, </button>
]; </div>
const adminIconPaths: Record<string, JSX.Element> = { <nav className="admin-nav">
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>, {adminNavItems.map(item => (
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>, <NavLink
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>, key={item.path}
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>, to={item.path}
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>, end={item.exact}
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>, className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>, >
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>, <span className="nav-icon">{adminIconPaths[item.icon]}</span>
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>, {!collapsed && <span className="nav-label">{item.label}</span>}
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>, </NavLink>
agents: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.4 8 12 8 12s8-6.6 8-12a8 8 0 0 0-8-8z"/></svg>, ))}
workflow: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></svg>, </nav>
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>, <div className="admin-sidebar-footer">
// New icons for orphan pages <NavLink to="/chat" className="admin-nav-item">
mcp: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>, <span className="nav-icon">
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="16" y="2" width="6" height="6" rx="1"/><rect x="2" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/><rect x="16" y="9" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/></svg>, <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>, </span>
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>, {!collapsed && <span className="nav-label">返回前台</span>}
developer: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>, </NavLink>
}; <button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
// Find current page title based on location <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
function getPageTitle(pathname: string): string { </span>
for (const group of navGroups) { {!collapsed && <span className="nav-label">退出登录</span>}
for (const item of group.items) { </button>
if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) { </div>
return item.label; {!collapsed && (
} <div className="admin-version-info">
} Qihu-AIFlow v2.0.0
} </div>
return ''; )}
} </aside>
// Generate breadcrumbs from pathname {/* Admin Main Content */}
function getBreadcrumbs(pathname: string): { path: string; label: string }[] { <main className="admin-main">
const crumbs: { path: string; label: string }[] = []; <header className="admin-topbar">
for (const group of navGroups) { <div className="admin-topbar-left">
for (const item of group.items) { <span className="admin-welcome">欢迎,{user.username}</span>
if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) { </div>
crumbs.push({ path: item.path, label: item.label }); <div className="admin-topbar-right">
} <a href="#/chat" className="admin-topbar-action">
} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
} <span>返回前台</span>
return crumbs.length > 0 ? crumbs : [{ path: '/admin', label: '管理后台' }]; </a>
} <div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
const AdminLayout: React.FC = () => { {user.username.charAt(0).toUpperCase()}
const { user, logout } = useAuth(); </div>
const navigate = useNavigate(); <span>{user.username}</span>
const location = useLocation(); </div>
const [collapsed, setCollapsed] = useState(false); </div>
const [expandedGroups, setExpandedGroups] = useState<Set<string>>( </header>
new Set(navGroups.map(g => g.label)) <div className="admin-content">
); <Outlet />
</div>
if (!user || user.role !== 'admin') { </main>
navigate('/login'); </div>
return null; );
} };
const toggleGroup = (label: string) => { export default AdminLayout;
const next = new Set(expandedGroups);
if (next.has(label)) next.delete(label);
else next.add(label);
setExpandedGroups(next);
};
const pageTitle = getPageTitle(location.pathname);
const breadcrumbs = getBreadcrumbs(location.pathname);
// Check if a nav item is active
const isItemActive = (item: NavItem) => {
return item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path);
};
// Check if any item in group is active
const isGroupActive = (group: NavGroup) => group.items.some(item => isItemActive(item));
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
<stop stopColor="#6C63FF"/>
<stop offset="1" stopColor="#A78BFA"/>
</linearGradient>
</defs>
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)} title={collapsed ? '展开菜单' : '折叠菜单'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{collapsed
? <><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></>
: <><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></>
}
</svg>
</button>
</div>
<nav className="admin-nav">
{navGroups.map(group => {
if (collapsed) {
// Collapsed mode: show just icons
return (
<div key={group.label} className="nav-group">
{group.items.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
title={item.label}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
</NavLink>
))}
</div>
);
}
return (
<div key={group.label} className={`nav-group ${isGroupActive(group) ? 'active-group' : ''}`}>
<div
className="nav-group-header"
onClick={() => toggleGroup(group.label)}
>
<span className="nav-group-label">{group.label}</span>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className={`nav-group-chevron ${expandedGroups.has(group.label) ? 'open' : ''}`}
>
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
<div
className={`nav-group-items ${expandedGroups.has(group.label) ? '' : 'collapsed'}`}
>
{group.items.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
<span className="nav-label">{item.label}</span>
{isItemActive(item) && <span className="nav-active-dot" />}
</NavLink>
))}
</div>
</div>
);
})}
</nav>
{/* Sidebar bottom area */}
<div className="admin-sidebar-footer">
{/* Links row */}
<NavLink to="/" className="admin-nav-item">
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
{!collapsed && <span className="nav-label">返回前台</span>}
</NavLink>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
{!collapsed && (
<div className="admin-version-info">
Qihu-AIFlow v2.0.0
</div>
)}
</aside>
{/* Admin Main Content */}
<main className="admin-main">
<header className="admin-topbar">
<div className="admin-topbar-left">
{/* Page title */}
<h1 className="admin-page-title">{pageTitle}</h1>
{/* Breadcrumb */}
<div className="admin-breadcrumb">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path}>
{i > 0 && <span className="breadcrumb-sep">/</span>}
<span
className={`breadcrumb-item ${i === breadcrumbs.length - 1 ? 'current' : ''}`}
onClick={() => i < breadcrumbs.length - 1 && navigate(crumb.path)}
style={{ cursor: i < breadcrumbs.length - 1 ? 'pointer' : 'default' }}
>
{crumb.label}
</span>
</span>
))}
</div>
</div>
<div className="admin-topbar-right">
<span className="admin-welcome">欢迎,{user.username}</span>
<a href="#/chat" className="admin-topbar-action">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>返回前台</span>
</a>
<div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span>{user.username}</span>
</div>
</div>
</header>
<div className="admin-content">
<Outlet />
</div>
</main>
</div>
);
};
export default AdminLayout;
+747
View File
@@ -0,0 +1,747 @@
import React, { useState, useEffect, useRef } from 'react';
import { adminAPI } from '@/api';
interface ExecStats {
total: number;
today: number;
success: number;
failed: number;
timeout: number;
avg_execution_time_ms: number;
by_language: Record<string, number>;
}
interface RuntimeInfo {
python?: string;
nodejs?: string;
max_code_length?: number;
max_timeout?: number;
}
interface TaskItem {
id: string;
language: string;
code_preview: string;
status: string;
exit_code: number | null;
execution_time_ms: number | null;
error_msg: string | null;
truncated: boolean;
created_at: string;
completed_at: string | null;
}
interface ExecResult {
task_id: string;
success: boolean;
stdout: string;
stderr: string;
exit_code: number;
execution_time_ms: number;
error: string | null;
truncated: boolean;
created_at: string;
}
const LANG_CONFIG: Record<string, { label: string; color: string; bg: string; placeholder: string }> = {
python: { label: 'Python', color: '#306998', bg: '#E8F4FD', placeholder: '# Python 代码示例\nprint("Hello, sandbox!")\n\nfor i in range(5):\n print(f"Line {i}")' },
javascript: { label: 'JavaScript', color: '#F7DF1E', bg: '#FFFDE8', placeholder: '// JavaScript 代码示例\nconsole.log("Hello, sandbox!");\n\nfor (let i = 0; i < 5; i++) {\n console.log(`Line ${i}`);\n}' },
};
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
error: { label: '错误', color: '#EF4444', bg: '#FEE2E2' },
failed: { label: '失败', color: '#F59E0B', bg: '#FEF3C7' },
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
};
const PAGE_SIZE = 15;
const CodeExec: React.FC = () => {
// Stats
const [stats, setStats] = useState<ExecStats | null>(null);
const [runtimes, setRuntimes] = useState<RuntimeInfo | null>(null);
// Editor
const [language, setLanguage] = useState('python');
const [code, setCode] = useState(LANG_CONFIG.python.placeholder);
const [timeout, setTimeout_] = useState(30);
// Execution
const [executing, setExecuting] = useState(false);
const [result, setResult] = useState<ExecResult | null>(null);
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout');
// Task History
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [filterLang, setFilterLang] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Task Detail Modal
const [detailTask, setDetailTask] = useState<TaskItem | null>(null);
const [detailResult, setDetailResult] = useState<ExecResult | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const outputRef = useRef<HTMLDivElement>(null);
// Load stats & runtimes on mount
useEffect(() => {
loadStats();
loadRuntimes();
}, []);
// Load tasks
useEffect(() => {
loadTasks();
}, [page, filterLang, filterStatus]);
const loadStats = async () => {
try {
const res = await adminAPI.getCodeExecStats() as any;
setStats(res.data || null);
} catch (e) { /* ignore */ }
};
const loadRuntimes = async () => {
try {
const res = await adminAPI.getCodeExecRuntimes() as any;
setRuntimes(res.data || null);
} catch (e) { /* ignore */ }
};
const loadTasks = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (filterLang) params.language = filterLang;
if (filterStatus) params.status = filterStatus;
const res = await adminAPI.getCodeExecTasks(params) as any;
const data = res.data || res;
setTasks(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast('加载任务列表失败: ' + (e.message || e), 'error');
} finally {
setLoading(false);
}
};
// Execute code
const handleExecute = async () => {
if (!code.trim()) {
showToast('请输入代码', 'error');
return;
}
setExecuting(true);
setResult(null);
setActiveTab('stdout');
try {
const res = await adminAPI.executeCode({ language, code: code.trim(), timeout }) as any;
setResult(res.data || res);
} catch (e: any) {
setResult({
task_id: '',
success: false,
stdout: '',
stderr: '',
exit_code: -1,
execution_time_ms: 0,
error: e.message || '执行失败',
truncated: false,
created_at: '',
});
showToast('执行失败: ' + (e.message || e), 'error');
} finally {
setExecuting(false);
loadStats();
loadTasks();
}
};
// View task detail
const viewTaskDetail = async (task: TaskItem) => {
setDetailTask(task);
setDetailResult(null);
setDetailLoading(true);
try {
const res = await adminAPI.getCodeExecTask(task.id) as any;
setDetailResult(res.data || null);
} catch (e: any) {
showToast('获取任务详情失败', 'error');
} finally {
setDetailLoading(false);
}
};
// Delete task
const handleDeleteTask = async (task: TaskItem) => {
if (!window.confirm(`确定要删除任务 ${task.id} 吗?`)) return;
try {
await adminAPI.deleteCodeExecTask(task.id);
showToast('删除成功');
if (detailTask?.id === task.id) {
setDetailTask(null);
setDetailResult(null);
}
loadTasks();
loadStats();
} catch (e: any) {
showToast('删除失败: ' + (e.message || e), 'error');
}
};
// Keyboard shortcut: Ctrl/Cmd+Enter to execute
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleExecute();
}
};
return (
<div className="admin-page">
<div className="admin-page-header">
<h2>代码执行沙箱</h2>
<span style={{ fontSize: '13px', color: '#6B7280', marginLeft: '12px' }}>
安全隔离的 Python / JavaScript 代码执行环境
</span>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
<div className="stat-card">
<div className="stat-value">{stats.today}</div>
<div className="stat-label">今日执行</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.success}</div>
<div className="stat-label">成功</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#EF4444' }}>{stats.failed + stats.timeout}</div>
<div className="stat-label">失败/超时</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.avg_execution_time_ms.toFixed(0)}ms</div>
<div className="stat-label">平均耗时</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">总执行次数</div>
</div>
{/* Language breakdown */}
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginTop: '4px' }}>
{Object.entries(stats.by_language || {}).map(([lang, count]) => (
<span key={lang} style={{
padding: '2px 12px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[lang] || {}).color || '#666',
background: (LANG_CONFIG[lang] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[lang] || {}).label || lang}: {count}
</span>
))}
{Object.keys(stats.by_language || {}).length === 0 && (
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>暂无数据</span>
)}
</div>
<div className="stat-label">语言分布</div>
</div>
</div>
)}
{/* Main grid: Editor + Output */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '24px' }}>
{/* Left: Code Editor */}
<div style={{
background: '#1E1E2E', borderRadius: '12px', border: '1px solid #313244',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Editor header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #313244',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FF5F57' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FFBD2E' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#27CA40' }} />
</div>
<select
value={language}
onChange={(e) => {
setLanguage(e.target.value);
setCode(LANG_CONFIG[e.target.value]?.placeholder || '');
setResult(null);
}}
style={{
padding: '4px 12px', borderRadius: '6px', border: '1px solid #45475A',
background: '#313244', color: '#CDD6F4', fontSize: '13px', cursor: 'pointer',
}}
>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<span style={{ fontSize: '12px', color: '#6C7086' }}>
{runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '12px', color: '#6C7086' }}>超时:</span>
<input
type="range"
min={1}
max={120}
value={timeout}
onChange={(e) => setTimeout_(parseInt(e.target.value))}
style={{ width: '80px', accentColor: '#6C63FF' }}
/>
<span style={{ fontSize: '12px', color: '#CDD6F4', minWidth: '28px' }}>{timeout}s</span>
<button
onClick={handleExecute}
disabled={executing}
style={{
padding: '6px 16px', borderRadius: '6px', border: 'none',
background: executing ? '#45475A' : '#6C63FF',
color: '#fff', cursor: executing ? 'not-allowed' : 'pointer',
fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
}}
>
{executing ? '执行中...' : '▶ 运行'}
</button>
</div>
</div>
{/* Code textarea */}
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={LANG_CONFIG[language]?.placeholder || '输入代码...'}
spellCheck={false}
style={{
flex: 1, minHeight: '320px', padding: '16px', border: 'none',
background: '#1E1E2E', color: '#CDD6F4', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
fontSize: '13px', lineHeight: 1.6, resize: 'vertical',
outline: 'none', tabSize: 4,
}}
/>
{/* Footer hint */}
<div style={{
padding: '6px 16px', borderTop: '1px solid #313244',
fontSize: '11px', color: '#585B70',
}}>
Ctrl+Enter 运行 · {language === 'python' ? 'Python 3.11' : 'Node.js'} · 最大 {runtimes?.max_code_length ? (runtimes.max_code_length / 1024).toFixed(0) + 'KB' : '50KB'}
</div>
</div>
{/* Right: Output */}
<div style={{
background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Output header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #E5E7EB',
background: '#F9FAFB',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#374151' }}>执行结果</span>
{result && (
<span style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '11px',
color: result.success ? '#fff' : '#fff',
background: result.success ? '#10B981' : (result.error ? '#EF4444' : '#F59E0B'),
}}>
{result.success ? '成功' : (result.error?.includes('timeout') ? '超时' : '失败')}
</span>
)}
{result && !result.success && result.exit_code !== 0 && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
exit_code={result.exit_code}
</span>
)}
{result && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
{result.execution_time_ms.toFixed(0)}ms
</span>
)}
</div>
{result && (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => setActiveTab('stdout')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stdout' ? '#6C63FF' : '#F3F4F6',
color: activeTab === 'stdout' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stdout
</button>
<button
onClick={() => setActiveTab('stderr')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stderr' ? '#EF4444' : '#F3F4F6',
color: activeTab === 'stderr' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stderr{result.stderr ? ` (${result.stderr.length})` : ''}
</button>
</div>
)}
</div>
{/* Output content */}
<div ref={outputRef} style={{
flex: 1, minHeight: '320px', padding: '16px', overflow: 'auto',
background: '#1E1E2E',
}}>
{executing ? (
<div style={{ color: '#6C7086', fontSize: '13px', fontFamily: 'monospace' }}>
<span style={{ animation: 'blink 1s infinite' }}>⏳</span> 正在执行...
</div>
) : !result ? (
<div style={{ color: '#585B70', fontSize: '13px', fontFamily: 'monospace', textAlign: 'center', paddingTop: '60px' }}>
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{'</>'}</div>
<div>点击「运行」按钮执行代码</div>
<div style={{ marginTop: '8px', fontSize: '11px' }}>或按 Ctrl+Enter 快捷运行</div>
</div>
) : (
<>
{result.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px',
fontFamily: "'Fira Code', monospace",
}}>
{result.error}
</div>
)}
{result.truncated && (
<div style={{
padding: '4px 12px', marginBottom: '8px', borderRadius: '4px',
background: '#FEF3C7', color: '#92400E', fontSize: '11px', fontFamily: 'monospace',
}}>
输出已截断 (超过 500KB)
</div>
)}
<pre style={{
margin: 0, padding: 0, background: 'transparent', color: '#CDD6F4',
fontSize: '13px', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
minHeight: activeTab === 'stdout' && !result.stdout.trim() ? 'auto' : undefined,
}}>
{activeTab === 'stdout'
? (result.stdout.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
: (result.stderr.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
}
</pre>
{result.task_id && (
<div style={{ marginTop: '12px', padding: '6px 0', borderTop: '1px solid #313244', fontSize: '11px', color: '#585B70', fontFamily: 'monospace' }}>
Task ID: {result.task_id}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Task History */}
<div style={{ marginTop: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', color: '#374151' }}>执行历史</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<select
value={filterLang}
onChange={(e) => { setFilterLang(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部语言</option>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部状态</option>
<option value="completed">成功</option>
<option value="error">错误</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
</select>
<button
onClick={loadTasks}
style={{
padding: '6px 12px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#374151',
}}
>
刷新
</button>
</div>
</div>
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '100px' }}>任务ID</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>语言</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>代码预览</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>状态</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>耗时</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '160px' }}>创建时间</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '120px' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
) : tasks.length === 0 ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</td></tr>
) : (
tasks.map(task => (
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={{ padding: '10px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6C63FF' }}>
{task.id}
</td>
<td style={{ padding: '10px 16px' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (LANG_CONFIG[task.language] || {}).color || '#666',
background: (LANG_CONFIG[task.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[task.language] || {}).label || task.language}
</span>
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.code_preview || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (STATUS_CONFIG[task.status] || {}).color || '#666',
background: (STATUS_CONFIG[task.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[task.status] || {}).label || task.status}
</span>
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
{task.execution_time_ms != null ? `${task.execution_time_ms}ms` : '-'}
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280' }}>
{task.created_at || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
<button
onClick={() => viewTaskDetail(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '11px', color: '#374151',
}}
>
详情
</button>
<button
onClick={() => handleDeleteTask(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #FEE2E2',
background: '#FEF2F2', cursor: 'pointer', fontSize: '11px', color: '#EF4444',
}}
>
删除
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer',
opacity: page <= 1 ? 0.5 : 1,
}}
>
上一页
</button>
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
</span>
<button
disabled={page >= Math.ceil(total / PAGE_SIZE)}
onClick={() => setPage(p => p + 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
}}
>
下一页
</button>
</div>
)}
</div>
{/* Task Detail Modal */}
{detailTask && (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
}} onClick={() => { setDetailTask(null); setDetailResult(null); }}>
<div className="modal-content" style={{
background: '#fff', borderRadius: '12px', padding: '24px',
width: '700px', maxHeight: '85vh', overflow: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>任务详情 - {detailTask.id}</h3>
<button
onClick={() => { setDetailTask(null); setDetailResult(null); }}
style={{ padding: '4px 8px', borderRadius: '4px', border: 'none', background: '#F3F4F6', cursor: 'pointer', fontSize: '16px', color: '#6B7280' }}
>
✕
</button>
</div>
{/* Task info bar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[detailTask.language] || {}).color || '#666',
background: (LANG_CONFIG[detailTask.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[detailTask.language] || {}).label || detailTask.language}
</span>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (STATUS_CONFIG[detailTask.status] || {}).color || '#666',
background: (STATUS_CONFIG[detailTask.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[detailTask.status] || {}).label || detailTask.status}
</span>
{detailTask.execution_time_ms != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
耗时: {detailTask.execution_time_ms}ms
</span>
)}
{detailTask.exit_code != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
exit_code: {detailTask.exit_code}
</span>
)}
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>
{detailTask.created_at}
</span>
</div>
{/* Code preview */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>代码预览</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#1E1E2E', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '150px', overflow: 'auto',
}}>
{detailTask.code_preview || '(无)'}
</pre>
</div>
{/* Full output */}
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div>
) : detailResult ? (
<>
{detailResult.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px', fontFamily: 'monospace',
}}>
{detailResult.error}
</div>
)}
{detailResult.stdout && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>stdout</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#1E1E2E', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stdout}
</pre>
</div>
)}
{detailResult.stderr && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#EF4444', fontWeight: 500 }}>stderr</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#1E1E2E', color: '#F38BA8', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stderr}
</pre>
</div>
)}
{!detailResult.stdout.trim() && !detailResult.stderr.trim() && !detailResult.error && (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>(无输出)</div>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>无法获取详细信息</div>
)}
</div>
</div>
)}
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
padding: '12px 24px', borderRadius: '8px', color: '#fff',
background: toast.type === 'success' ? '#10B981' : '#EF4444',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
}}>
{toast.msg}
</div>
)}
</div>
);
};
export default CodeExec;
+23
View File
@@ -0,0 +1,23 @@
-- G2: Code Execution Worker - Database Migration
-- Creates code_exec_tasks table for tracking code execution tasks
CREATE TABLE IF NOT EXISTS code_exec_tasks (
id VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'Task ID (UUID short)',
language VARCHAR(20) NOT NULL DEFAULT 'python' COMMENT 'Programming language',
code_preview VARCHAR(500) DEFAULT '' COMMENT 'First 200 chars of code for preview',
status ENUM('running','completed','error','failed','cancelled') NOT NULL DEFAULT 'running' COMMENT 'Execution status',
timeout_sec INT NOT NULL DEFAULT 30 COMMENT 'Timeout in seconds',
exit_code INT DEFAULT NULL COMMENT 'Process exit code',
execution_time_ms FLOAT DEFAULT 0 COMMENT 'Execution time in milliseconds',
stdout MEDIUMTEXT COMMENT 'Standard output (max 50KB)',
stderr MEDIUMTEXT COMMENT 'Standard error (max 50KB)',
error_msg VARCHAR(2048) DEFAULT NULL COMMENT 'Error message if failed',
truncated TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Whether output was truncated',
created_at DATETIME NOT NULL COMMENT 'Task creation time',
completed_at DATETIME DEFAULT NULL COMMENT 'Task completion time',
INDEX idx_language (language),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_lang_status (language, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Code execution task records';