Phase 3.1: OSS storage config management (MinIO/AliyunOSS/AWS S3)
Backend: - Add OSSStorageConfig model (models.py) - Create storage_admin.py API (9 endpoints): stats, CRUD, toggle, set-default, test connection - Register route /app/api/admin/storage in main.py - Create oss_storage_configs table in MySQL Frontend: - Add Storage.tsx admin page (713 lines) with stats, table, create/edit modal - Register /admin/storage route in App.tsx - Add nav item and icon in AdminLayout.tsx - Add 9 API methods in api/index.ts
This commit is contained in:
1 parent
5d1ad26874
commit
b2ba75a666
7 files changed
+2265
-468
No files matched your search
@@ -0,0 +1,582 @@
|
|||||||
|
"""
|
||||||
|
OSS存储配置管理 API
|
||||||
|
Phase 3.1: 支持 MinIO / 阿里云OSS / AWS S3 等对象存储配置
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||||
|
from sqlalchemy import or_, func, desc, asc, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ...database import get_db
|
||||||
|
from ...models import (
|
||||||
|
gen_uuid,
|
||||||
|
)
|
||||||
|
from ...auth import get_current_admin
|
||||||
|
from ...middleware.log_service import LogService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger("aiflow")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Pydantic Models
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class OSSConfigCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
provider: str = Field(min_length=1, max_length=20)
|
||||||
|
endpoint: str = Field(min_length=1, max_length=500)
|
||||||
|
access_key: str = Field(min_length=1, max_length=255)
|
||||||
|
secret_key: str = Field(min_length=1, max_length=255)
|
||||||
|
bucket: str = Field(min_length=1, max_length=200)
|
||||||
|
region: Optional[str] = ""
|
||||||
|
base_path: Optional[str] = ""
|
||||||
|
is_default: bool = False
|
||||||
|
is_enabled: bool = True
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OSSConfigUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
provider: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||||
|
endpoint: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||||
|
access_key: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
secret_key: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
bucket: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
region: Optional[str] = None
|
||||||
|
base_path: Optional[str] = None
|
||||||
|
is_default: Optional[bool] = None
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OSSConnectionTest(BaseModel):
|
||||||
|
"""测试连接请求 - 可以测试已保存的配置或临时配置"""
|
||||||
|
config_id: Optional[str] = None # 已保存的配置ID
|
||||||
|
# 以下为临时测试参数
|
||||||
|
provider: Optional[str] = None
|
||||||
|
endpoint: Optional[str] = None
|
||||||
|
access_key: Optional[str] = None
|
||||||
|
secret_key: Optional[str] = None
|
||||||
|
bucket: Optional[str] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def config_to_dict(config) -> dict:
|
||||||
|
"""将OSSStorageConfig模型转换为字典"""
|
||||||
|
result = {
|
||||||
|
"id": config.id,
|
||||||
|
"name": config.name,
|
||||||
|
"provider": config.provider,
|
||||||
|
"endpoint": config.endpoint,
|
||||||
|
"access_key": config.access_key[:8] + "****" if config.access_key and len(config.access_key) > 8 else "****",
|
||||||
|
"secret_key": "****", # 永远不返回完整密钥
|
||||||
|
"bucket": config.bucket,
|
||||||
|
"region": config.region or "",
|
||||||
|
"base_path": config.base_path or "",
|
||||||
|
"is_default": getattr(config, "is_default", False),
|
||||||
|
"is_enabled": config.is_enabled,
|
||||||
|
"status": config.status or "disconnected",
|
||||||
|
"created_at": config.created_at.isoformat() if config.created_at else None,
|
||||||
|
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 安全处理 JSON 字段
|
||||||
|
config_raw = getattr(config, "config", None)
|
||||||
|
if config_raw:
|
||||||
|
try:
|
||||||
|
result["config"] = json.loads(config_raw) if isinstance(config_raw, str) else config_raw
|
||||||
|
except Exception:
|
||||||
|
result["config"] = {}
|
||||||
|
else:
|
||||||
|
result["config"] = {}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str,
|
||||||
|
bucket: str, region: str = "") -> Dict[str, Any]:
|
||||||
|
"""测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider in ("minio", "aws_s3"):
|
||||||
|
# 使用 AWS S3 兼容 API 测试
|
||||||
|
# ListBuckets 请求
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
host = parsed.netloc or parsed.path
|
||||||
|
if not host:
|
||||||
|
return {"success": False, "error": "无法解析端点地址", "latency_ms": 0}
|
||||||
|
|
||||||
|
# 先尝试简单的 bucket HEAD 请求
|
||||||
|
bucket_url = f"{endpoint.rstrip('/')}/{bucket}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||||
|
resp = await client.head(bucket_url)
|
||||||
|
latency = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code in (200, 403):
|
||||||
|
# 200: bucket存在且有权限; 403: bucket存在但无权限(也是连通的表现)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"连接成功!Bucket '{bucket}' 可访问",
|
||||||
|
"latency_ms": latency,
|
||||||
|
"status_code": resp.status_code
|
||||||
|
}
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Bucket '{bucket}' 不存在",
|
||||||
|
"latency_ms": latency
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"连接失败: HTTP {resp.status_code}",
|
||||||
|
"latency_ms": latency
|
||||||
|
}
|
||||||
|
|
||||||
|
elif provider == "aliyun_oss":
|
||||||
|
# 阿里云OSS: 简单HEAD请求
|
||||||
|
bucket_url = f"{endpoint.rstrip('/')}/{bucket}" if not endpoint.endswith(bucket) else endpoint
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||||
|
resp = await client.head(bucket_url)
|
||||||
|
latency = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code in (200, 403):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"连接成功!Bucket '{bucket}' 可访问",
|
||||||
|
"latency_ms": latency,
|
||||||
|
"status_code": resp.status_code
|
||||||
|
}
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Bucket '{bucket}' 不存在",
|
||||||
|
"latency_ms": latency
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"连接失败: HTTP {resp.status_code}",
|
||||||
|
"latency_ms": latency
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"不支持的存储类型: {provider}", "latency_ms": 0}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
latency = int((time.time() - start_time) * 1000)
|
||||||
|
return {"success": False, "error": f"无法连接到 {endpoint}", "latency_ms": latency}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
latency = int((time.time() - start_time) * 1000)
|
||||||
|
return {"success": False, "error": "连接超时(10秒)", "latency_ms": latency}
|
||||||
|
except Exception as e:
|
||||||
|
latency = int((time.time() - start_time) * 1000)
|
||||||
|
return {"success": False, "error": f"测试失败: {str(e)}", "latency_ms": latency}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/stats/overview", summary="存储配置统计概览")
|
||||||
|
async def get_storage_stats(
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取存储配置的统计信息"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
total = db.query(func.count(OSSStorageConfig.id)).scalar() or 0
|
||||||
|
enabled_count = db.query(func.count(OSSStorageConfig.id)).filter(
|
||||||
|
OSSStorageConfig.is_enabled == True
|
||||||
|
).scalar() or 0
|
||||||
|
default_count = db.query(func.count(OSSStorageConfig.id)).filter(
|
||||||
|
OSSStorageConfig.is_default == True
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 按类型统计
|
||||||
|
by_provider = {}
|
||||||
|
providers = db.query(OSSStorageConfig.provider, func.count(OSSStorageConfig.id)).group_by(
|
||||||
|
OSSStorageConfig.provider
|
||||||
|
).all()
|
||||||
|
for provider, count in providers:
|
||||||
|
by_provider[provider] = count
|
||||||
|
|
||||||
|
# 按状态统计
|
||||||
|
by_status = {}
|
||||||
|
statuses = db.query(OSSStorageConfig.status, func.count(OSSStorageConfig.id)).group_by(
|
||||||
|
OSSStorageConfig.status
|
||||||
|
).all()
|
||||||
|
for status, count in statuses:
|
||||||
|
by_status[status] = count
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"enabled_count": enabled_count,
|
||||||
|
"default_count": default_count,
|
||||||
|
"by_provider": by_provider,
|
||||||
|
"by_status": by_status,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", summary="获取OSS存储配置列表")
|
||||||
|
async def list_storage_configs(
|
||||||
|
provider: str = Query(None, description="按提供商筛选"),
|
||||||
|
is_enabled: bool = Query(None, description="按启用状态筛选"),
|
||||||
|
keyword: str = Query(None, description="搜索关键词"),
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
"""获取OSS存储配置列表,支持分页和筛选"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
query = db.query(OSSStorageConfig)
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
query = query.filter(OSSStorageConfig.provider == provider)
|
||||||
|
if is_enabled is not None:
|
||||||
|
query = query.filter(OSSStorageConfig.is_enabled == is_enabled)
|
||||||
|
if keyword:
|
||||||
|
keyword_filter = or_(
|
||||||
|
OSSStorageConfig.name.ilike(f"%{keyword}%"),
|
||||||
|
OSSStorageConfig.endpoint.ilike(f"%{keyword}%"),
|
||||||
|
OSSStorageConfig.bucket.ilike(f"%{keyword}%"),
|
||||||
|
)
|
||||||
|
query = query.filter(keyword_filter)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.order_by(desc(OSSStorageConfig.is_default), desc(OSSStorageConfig.updated_at)) \
|
||||||
|
.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"items": [config_to_dict(item) for item in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}", summary="获取OSS存储配置详情")
|
||||||
|
async def get_storage_config(
|
||||||
|
config_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取单个OSS存储配置的详细信息"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": config_to_dict(config),
|
||||||
|
"message": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", summary="创建OSS存储配置")
|
||||||
|
async def create_storage_config(
|
||||||
|
req: OSSConfigCreate,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""创建新的OSS存储配置"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
# 检查名称重复
|
||||||
|
existing = db.query(OSSStorageConfig).filter(OSSStorageConfig.name == req.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="配置名称已存在")
|
||||||
|
|
||||||
|
# 如果设为默认,先取消其他默认
|
||||||
|
if req.is_default:
|
||||||
|
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
|
||||||
|
{"is_default": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
config = OSSStorageConfig(
|
||||||
|
id=gen_uuid(),
|
||||||
|
name=req.name,
|
||||||
|
provider=req.provider,
|
||||||
|
endpoint=req.endpoint,
|
||||||
|
access_key=req.access_key,
|
||||||
|
secret_key=req.secret_key,
|
||||||
|
bucket=req.bucket,
|
||||||
|
region=req.region or "",
|
||||||
|
base_path=req.base_path or "",
|
||||||
|
is_default=req.is_default,
|
||||||
|
is_enabled=req.is_enabled,
|
||||||
|
status="disconnected",
|
||||||
|
config=req.config,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(config)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
|
||||||
|
# 审计日志
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="create_oss_config",
|
||||||
|
target_type="oss_storage_config",
|
||||||
|
target_id=config.id,
|
||||||
|
detail=f"创建了OSS存储配置: {config.name} ({config.provider})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": config_to_dict(config),
|
||||||
|
"message": "创建成功"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}", summary="更新OSS存储配置")
|
||||||
|
async def update_storage_config(
|
||||||
|
config_id: str,
|
||||||
|
req: OSSConfigUpdate,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新OSS存储配置"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
|
||||||
|
# 检查名称重复
|
||||||
|
if req.name is not None and req.name != config.name:
|
||||||
|
existing = db.query(OSSStorageConfig).filter(
|
||||||
|
OSSStorageConfig.name == req.name,
|
||||||
|
OSSStorageConfig.id != config_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="配置名称已存在")
|
||||||
|
|
||||||
|
# 如果设为默认,先取消其他默认
|
||||||
|
if req.is_default and not config.is_default:
|
||||||
|
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
|
||||||
|
{"is_default": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
|
update_fields = ["name", "provider", "endpoint", "access_key", "secret_key",
|
||||||
|
"bucket", "region", "base_path", "is_default", "is_enabled"]
|
||||||
|
for field in update_fields:
|
||||||
|
value = getattr(req, field, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(config, field, value)
|
||||||
|
# 如果修改了连接参数,重置状态
|
||||||
|
if field in ("endpoint", "access_key", "secret_key", "bucket", "provider"):
|
||||||
|
config.status = "disconnected"
|
||||||
|
|
||||||
|
if req.config is not None:
|
||||||
|
config.config = req.config
|
||||||
|
|
||||||
|
config.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
|
||||||
|
# 审计日志
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="update_oss_config",
|
||||||
|
target_type="oss_storage_config",
|
||||||
|
target_id=config.id,
|
||||||
|
detail=f"更新了OSS存储配置: {config.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": config_to_dict(config),
|
||||||
|
"message": "更新成功"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}", summary="删除OSS存储配置")
|
||||||
|
async def delete_storage_config(
|
||||||
|
config_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除OSS存储配置"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
|
||||||
|
name = config.name
|
||||||
|
db.delete(config)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 审计日志
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="delete_oss_config",
|
||||||
|
target_type="oss_storage_config",
|
||||||
|
target_id=config_id,
|
||||||
|
detail=f"删除了OSS存储配置: {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": None,
|
||||||
|
"message": f"已删除配置: {name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}/toggle", summary="启用/禁用存储配置")
|
||||||
|
async def toggle_storage_config(
|
||||||
|
config_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""切换存储配置的启用/禁用状态"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
|
||||||
|
config.is_enabled = not config.is_enabled
|
||||||
|
config.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
|
||||||
|
action = "启用" if config.is_enabled else "禁用"
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="toggle_oss_config",
|
||||||
|
target_type="oss_storage_config",
|
||||||
|
target_id=config.id,
|
||||||
|
detail=f"{action}了OSS存储配置: {config.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": config_to_dict(config),
|
||||||
|
"message": f"已{action}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}/set-default", summary="设为默认存储配置")
|
||||||
|
async def set_default_storage_config(
|
||||||
|
config_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""将指定配置设为默认存储"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
|
||||||
|
# 取消所有默认
|
||||||
|
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
|
||||||
|
{"is_default": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置新默认
|
||||||
|
config.is_default = True
|
||||||
|
config.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
|
||||||
|
LogService.log_audit(
|
||||||
|
admin_id=admin_user.id,
|
||||||
|
admin_name=admin_user.username,
|
||||||
|
action="set_default_oss_config",
|
||||||
|
target_type="oss_storage_config",
|
||||||
|
target_id=config.id,
|
||||||
|
detail=f"将 {config.name} 设为默认存储配置"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": config_to_dict(config),
|
||||||
|
"message": f"已将 {config.name} 设为默认存储"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", summary="测试OSS连接")
|
||||||
|
async def test_storage_connection(
|
||||||
|
req: OSSConnectionTest,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""测试OSS存储连接"""
|
||||||
|
from ...models import OSSStorageConfig
|
||||||
|
|
||||||
|
provider = req.provider
|
||||||
|
endpoint = req.endpoint
|
||||||
|
access_key = req.access_key
|
||||||
|
secret_key = req.secret_key
|
||||||
|
bucket = req.bucket
|
||||||
|
region = req.region or ""
|
||||||
|
|
||||||
|
# 如果提供了config_id,从数据库获取配置
|
||||||
|
if req.config_id:
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="存储配置不存在")
|
||||||
|
provider = config.provider
|
||||||
|
endpoint = config.endpoint
|
||||||
|
access_key = config.access_key
|
||||||
|
secret_key = config.secret_key
|
||||||
|
bucket = config.bucket
|
||||||
|
region = config.region or ""
|
||||||
|
|
||||||
|
if not all([provider, endpoint, access_key, secret_key, bucket]):
|
||||||
|
raise HTTPException(status_code=400, detail="缺少必要的连接参数")
|
||||||
|
|
||||||
|
result = await test_oss_connection(provider, endpoint, access_key, secret_key, bucket, region)
|
||||||
|
|
||||||
|
# 如果是已保存的配置,更新连接状态
|
||||||
|
if req.config_id:
|
||||||
|
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first()
|
||||||
|
if config:
|
||||||
|
config.status = "connected" if result["success"] else "disconnected"
|
||||||
|
config.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": result,
|
||||||
|
"message": "连接测试完成"
|
||||||
|
}
|
||||||
@@ -979,3 +979,22 @@ class ExternalKBService(Base):
|
|||||||
status = Column(String(20), default="disconnected")
|
status = Column(String(20), default="disconnected")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
class OSSStorageConfig(Base):
|
||||||
|
__tablename__ = "oss_storage_configs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
provider = Column(String(20), nullable=False)
|
||||||
|
endpoint = Column(String(500), nullable=False)
|
||||||
|
access_key = Column(String(255), nullable=False)
|
||||||
|
secret_key = Column(String(255), nullable=False)
|
||||||
|
bucket = Column(String(200), nullable=False)
|
||||||
|
region = Column(String(100), default="")
|
||||||
|
base_path = Column(String(300), default="")
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
is_enabled = Column(Boolean, default=True)
|
||||||
|
status = Column(String(20), default="disconnected")
|
||||||
|
config = Column(JSON, default=None)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
+430
-215
@@ -1,217 +1,432 @@
|
|||||||
"""␍
|
"""
|
||||||
Qihu-AIFlow FastAPI 主应用␍
|
|
||||||
完整后端API,监听18080端口,所有接口以 /app 为前缀␍
|
Qihu-AIFlow FastAPI 主应用
|
||||||
"""␍
|
|
||||||
import sys␍
|
完整后端API,监听18080端口,所有接口以 /app 为前缀
|
||||||
import os␍
|
|
||||||
import time␍
|
"""
|
||||||
import uuid␍
|
|
||||||
import logging␍
|
import sys
|
||||||
from contextlib import asynccontextmanager␍
|
|
||||||
from fastapi import FastAPI, Request␍
|
import os
|
||||||
from fastapi.middleware.cors import CORSMiddleware␍
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware␍
|
import time
|
||||||
from datetime import datetime␍
|
|
||||||
␍
|
import uuid
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))␍
|
|
||||||
␍
|
import logging
|
||||||
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
|
from contextlib import asynccontextmanager
|
||||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD␍
|
|
||||||
from app.database import SessionLocal␍
|
from fastapi import FastAPI, Request
|
||||||
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel␍
|
|
||||||
␍
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)␍
|
|
||||||
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册␍
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
␍
|
|
||||||
# 初始化文件日志(按天轮转)␍
|
from datetime import datetime
|
||||||
access_logger = setup_file_logging()␍
|
|
||||||
logger = logging.getLogger("aiflow")␍
|
|
||||||
␍
|
|
||||||
␍
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
class APITrackingMiddleware(BaseHTTPMiddleware):␍
|
|
||||||
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""␍
|
|
||||||
␍
|
|
||||||
async def dispatch(self, request: Request, call_next):␍
|
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||||
request_id = str(uuid.uuid4())␍
|
|
||||||
start_time = time.time()␍
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin
|
||||||
path = request.url.path␍
|
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||||
method = request.method␍
|
|
||||||
␍
|
from app.database import SessionLocal
|
||||||
# 跳过健康检查和静态资源␍
|
|
||||||
skip_paths = ["/app/api/health", "/favicon.ico"]␍
|
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
|
||||||
should_skip = any(path.startswith(p) for p in skip_paths)␍
|
|
||||||
␍
|
|
||||||
try:␍
|
|
||||||
response = await call_next(request)␍
|
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
|
||||||
elapsed = time.time() - start_time␍
|
|
||||||
status_code = response.status_code␍
|
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
|
||||||
␍
|
|
||||||
if not should_skip:␍
|
|
||||||
access_logger.info(␍
|
|
||||||
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"␍
|
# 初始化文件日志(按天轮转)
|
||||||
)␍
|
|
||||||
␍
|
access_logger = setup_file_logging()
|
||||||
# 异步记录API调用统计␍
|
|
||||||
try:␍
|
logger = logging.getLogger("aiflow")
|
||||||
db = SessionLocal()␍
|
|
||||||
try:␍
|
|
||||||
_record_api_call(db, method, path, status_code, elapsed)␍
|
|
||||||
finally:␍
|
|
||||||
db.close()␍
|
|
||||||
except Exception as e:␍
|
class APITrackingMiddleware(BaseHTTPMiddleware):
|
||||||
logger.warning(f"API统计记录失败: {e}")␍
|
|
||||||
␍
|
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
|
||||||
return response␍
|
|
||||||
except Exception as e:␍
|
|
||||||
elapsed = time.time() - start_time␍
|
|
||||||
if not should_skip:␍
|
async def dispatch(self, request: Request, call_next):
|
||||||
access_logger.info(␍
|
|
||||||
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"␍
|
request_id = str(uuid.uuid4())
|
||||||
)␍
|
|
||||||
raise␍
|
start_time = time.time()
|
||||||
␍
|
|
||||||
async def __call__(self, scope, receive, send):␍
|
path = request.url.path
|
||||||
if scope["type"] != "http":␍
|
|
||||||
await self.app(scope, receive, send)␍
|
method = request.method
|
||||||
return␍
|
|
||||||
await super().__call__(scope, receive, send)␍
|
|
||||||
␍
|
|
||||||
␍
|
# 跳过健康检查和静态资源
|
||||||
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):␍
|
|
||||||
"""记录API调用到数据库统计表"""␍
|
skip_paths = ["/app/api/health", "/favicon.ico"]
|
||||||
# 简化路径以减少基数(去掉动态ID)␍
|
|
||||||
parts = path.split("/")␍
|
should_skip = any(path.startswith(p) for p in skip_paths)
|
||||||
simplified_parts = []␍
|
|
||||||
for part in parts:␍
|
|
||||||
# 将UUID样式的路径段替换为{id}␍
|
|
||||||
if len(part) == 36 and part.count("-") == 4:␍
|
try:
|
||||||
simplified_parts.append("{id}")␍
|
|
||||||
# 将纯数字路径段替换为{id}␍
|
response = await call_next(request)
|
||||||
elif part.isdigit():␍
|
|
||||||
simplified_parts.append("{id}")␍
|
elapsed = time.time() - start_time
|
||||||
else:␍
|
|
||||||
simplified_parts.append(part)␍
|
status_code = response.status_code
|
||||||
simplified_path = "/".join(simplified_parts)␍
|
|
||||||
␍
|
|
||||||
# 统计记录通过数据库实现,简化版记录到日志␍
|
|
||||||
is_error = status_code >= 400␍
|
if not should_skip:
|
||||||
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD␍
|
|
||||||
␍
|
access_logger.info(
|
||||||
if is_slow:␍
|
|
||||||
logger.warning(␍
|
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
|
||||||
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"␍
|
|
||||||
)␍
|
)
|
||||||
␍
|
|
||||||
␍
|
|
||||||
@asynccontextmanager␍
|
|
||||||
async def lifespan(app: FastAPI):␍
|
# 异步记录API调用统计
|
||||||
logger.info("Qihu-AIFlow v2.0.0 启动中...")␍
|
|
||||||
␍
|
try:
|
||||||
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)␍
|
|
||||||
try:␍
|
db = SessionLocal()
|
||||||
from app.services.evolution_service import EvolutionWatcherManager␍
|
|
||||||
from app.models import UserEvolutionConfig␍
|
try:
|
||||||
db = SessionLocal()␍
|
|
||||||
try:␍
|
_record_api_call(db, method, path, status_code, elapsed)
|
||||||
configs = db.query(UserEvolutionConfig).filter(␍
|
|
||||||
UserEvolutionConfig.watcher_status == "running"␍
|
finally:
|
||||||
).all()␍
|
|
||||||
if configs:␍
|
db.close()
|
||||||
watcher = EvolutionWatcherManager()␍
|
|
||||||
for cfg in configs:␍
|
except Exception as e:
|
||||||
try:␍
|
|
||||||
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())␍
|
logger.warning(f"API统计记录失败: {e}")
|
||||||
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")␍
|
|
||||||
except Exception as e:␍
|
|
||||||
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")␍
|
|
||||||
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")␍
|
return response
|
||||||
else:␍
|
|
||||||
logger.info("[Evolution] 无待恢复的进化配置")␍
|
except Exception as e:
|
||||||
finally:␍
|
|
||||||
db.close()␍
|
elapsed = time.time() - start_time
|
||||||
except Exception as e:␍
|
|
||||||
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")␍
|
if not should_skip:
|
||||||
␍
|
|
||||||
# 启动Redis事件总线␍
|
access_logger.info(
|
||||||
try:␍
|
|
||||||
from app.event_bus import get_event_bus␍
|
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
|
||||||
event_bus = get_event_bus()␍
|
|
||||||
event_bus.start(num_consumers=2)␍
|
)
|
||||||
logger.info("[EventBus] Redis事件总线已启动")␍
|
|
||||||
except Exception as e:␍
|
raise
|
||||||
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")␍
|
|
||||||
␍
|
|
||||||
logger.info("Qihu-AIFlow v2.0.0 启动完成")␍
|
|
||||||
␍
|
async def __call__(self, scope, receive, send):
|
||||||
yield␍
|
|
||||||
␍
|
if scope["type"] != "http":
|
||||||
# 停止Redis事件总线␍
|
|
||||||
try:␍
|
await self.app(scope, receive, send)
|
||||||
from app.event_bus import get_event_bus␍
|
|
||||||
get_event_bus().stop()␍
|
return
|
||||||
logger.info("[EventBus] Redis事件总线已停止")␍
|
|
||||||
except Exception:␍
|
await super().__call__(scope, receive, send)
|
||||||
pass␍
|
|
||||||
␍
|
|
||||||
# 优雅关闭:停止所有 Evolution Pipeline␍
|
|
||||||
try:␍
|
|
||||||
from app.services.evolution_service import EvolutionWatcherManager␍
|
|
||||||
EvolutionWatcherManager().shutdown_all()␍
|
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
|
||||||
logger.info("[Evolution] 所有Pipeline已停止")␍
|
|
||||||
except Exception:␍
|
"""记录API调用到数据库统计表"""
|
||||||
pass␍
|
|
||||||
␍
|
# 简化路径以减少基数(去掉动态ID)
|
||||||
logger.info("Qihu-AIFlow 正在关闭...")␍
|
|
||||||
␍
|
parts = path.split("/")
|
||||||
␍
|
|
||||||
app = FastAPI(␍
|
simplified_parts = []
|
||||||
title="Qihu-AIFlow - 智能自动化PaaS平台",␍
|
|
||||||
version="1.0.0",␍
|
for part in parts:
|
||||||
lifespan=lifespan,␍
|
|
||||||
)␍
|
# 将UUID样式的路径段替换为{id}
|
||||||
␍
|
|
||||||
# API调用追踪中间件␍
|
if len(part) == 36 and part.count("-") == 4:
|
||||||
app.add_middleware(APITrackingMiddleware)␍
|
|
||||||
␍
|
simplified_parts.append("{id}")
|
||||||
# CORS配置␍
|
|
||||||
app.add_middleware(␍
|
# 将纯数字路径段替换为{id}
|
||||||
CORSMiddleware,␍
|
|
||||||
allow_origins=["*"],␍
|
elif part.isdigit():
|
||||||
allow_credentials=True,␍
|
|
||||||
allow_methods=["*"],␍
|
simplified_parts.append("{id}")
|
||||||
allow_headers=["*"],␍
|
|
||||||
)␍
|
else:
|
||||||
␍
|
|
||||||
# 前台 API␍
|
simplified_parts.append(part)
|
||||||
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])␍
|
|
||||||
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])␍
|
simplified_path = "/".join(simplified_parts)
|
||||||
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])␍
|
|
||||||
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])␍
|
|
||||||
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])␍
|
|
||||||
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])␍
|
# 统计记录通过数据库实现,简化版记录到日志
|
||||||
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])␍
|
|
||||||
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])␍
|
is_error = status_code >= 400
|
||||||
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])␍
|
|
||||||
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])␍
|
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
|
||||||
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])␍
|
|
||||||
␍
|
|
||||||
# 后台 API␍
|
|
||||||
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])␍
|
if is_slow:
|
||||||
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])␍
|
|
||||||
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])␍
|
logger.warning(
|
||||||
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])␍
|
|
||||||
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])␍
|
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
|
||||||
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])␍
|
|
||||||
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])␍
|
)
|
||||||
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])␍
|
|
||||||
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])␍
|
|
||||||
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])␍
|
|
||||||
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])␍
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
|
logger.info("Qihu-AIFlow v2.0.0 启动中...")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
from app.services.evolution_service import EvolutionWatcherManager
|
||||||
|
|
||||||
|
from app.models import UserEvolutionConfig
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
configs = db.query(UserEvolutionConfig).filter(
|
||||||
|
|
||||||
|
UserEvolutionConfig.watcher_status == "running"
|
||||||
|
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if configs:
|
||||||
|
|
||||||
|
watcher = EvolutionWatcherManager()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
|
||||||
|
|
||||||
|
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
logger.info("[Evolution] 无待恢复的进化配置")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 启动Redis事件总线
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
from app.event_bus import get_event_bus
|
||||||
|
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
|
||||||
|
event_bus.start(num_consumers=2)
|
||||||
|
|
||||||
|
logger.info("[EventBus] Redis事件总线已启动")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("Qihu-AIFlow v2.0.0 启动完成")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 停止Redis事件总线
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
from app.event_bus import get_event_bus
|
||||||
|
|
||||||
|
get_event_bus().stop()
|
||||||
|
|
||||||
|
logger.info("[EventBus] Redis事件总线已停止")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 优雅关闭:停止所有 Evolution Pipeline
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
from app.services.evolution_service import EvolutionWatcherManager
|
||||||
|
|
||||||
|
EvolutionWatcherManager().shutdown_all()
|
||||||
|
|
||||||
|
logger.info("[Evolution] 所有Pipeline已停止")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("Qihu-AIFlow 正在关闭...")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
|
||||||
|
title="Qihu-AIFlow - 智能自动化PaaS平台",
|
||||||
|
|
||||||
|
version="1.0.0",
|
||||||
|
|
||||||
|
lifespan=lifespan,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# API调用追踪中间件
|
||||||
|
|
||||||
|
app.add_middleware(APITrackingMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
|
||||||
|
CORSMiddleware,
|
||||||
|
|
||||||
|
allow_origins=["*"],
|
||||||
|
|
||||||
|
allow_credentials=True,
|
||||||
|
|
||||||
|
allow_methods=["*"],
|
||||||
|
|
||||||
|
allow_headers=["*"],
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 前台 API
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
|
||||||
|
|
||||||
|
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
|
||||||
|
|
||||||
|
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
|
||||||
|
|
||||||
|
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
|
||||||
|
|
||||||
|
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
|
||||||
|
|
||||||
|
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
|
||||||
|
|
||||||
|
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
|
||||||
|
|
||||||
|
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
|
||||||
|
|
||||||
|
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
||||||
|
|
||||||
|
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
||||||
|
|
||||||
|
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 后台 API
|
||||||
|
|
||||||
|
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
||||||
|
|
||||||
|
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
|
||||||
|
|
||||||
|
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
|
||||||
|
|
||||||
|
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
|
||||||
|
|
||||||
|
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
|
||||||
|
|
||||||
|
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
|
||||||
|
|
||||||
|
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
|
||||||
|
|
||||||
|
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
|
||||||
|
|
||||||
|
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
|
||||||
|
|
||||||
|
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
|
||||||
|
|
||||||
|
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(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(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||||
␍
|
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||||
␍
|
|
||||||
@app.get("/app/api/health")␍
|
|
||||||
async def health_check():␍
|
|
||||||
return {"status": "ok", "service": "Qihu-AIFlow"}␍
|
|
||||||
|
@app.get("/app/api/health")
|
||||||
|
|
||||||
|
async def health_check():
|
||||||
|
|
||||||
|
return {"status": "ok", "service": "Qihu-AIFlow"}
|
||||||
|
|
||||||
+251
-126
@@ -1,128 +1,253 @@
|
|||||||
import React, { useState, useEffect } from 'react';␍
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';␍
|
|
||||||
import type { User } from '@/types';␍
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';␍
|
|
||||||
import { authAPI } from '@/api';␍
|
import type { User } from '@/types';
|
||||||
␍
|
|
||||||
// Layouts␍
|
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
|
||||||
import FrontLayout from '@/components/layout/FrontLayout';␍
|
|
||||||
import AdminLayout from '@/components/layout/AdminLayout';␍
|
import { authAPI } from '@/api';
|
||||||
␍
|
|
||||||
// Front Pages␍
|
|
||||||
import LandingPage from '@/pages/front/LandingPage';␍
|
|
||||||
import LoginPage from '@/pages/front/LoginPage';␍
|
// Layouts
|
||||||
import ChatPage from '@/pages/front/ChatPage';␍
|
|
||||||
import ChannelPage from '@/pages/front/ChannelPage';␍
|
import FrontLayout from '@/components/layout/FrontLayout';
|
||||||
import KnowledgePage from '@/pages/front/KnowledgePage';␍
|
|
||||||
import MarketPage from '@/pages/front/MarketPage';␍
|
import AdminLayout from '@/components/layout/AdminLayout';
|
||||||
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';␍
|
|
||||||
import PricingPage from '@/pages/front/PricingPage';␍
|
|
||||||
import ProfilePage from '@/pages/front/ProfilePage';␍
|
|
||||||
import NotificationPage from '@/pages/front/NotificationPage';␍
|
// Front Pages
|
||||||
import AssetsPage from '@/pages/front/AssetsPage';␍
|
|
||||||
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';␍
|
import LandingPage from '@/pages/front/LandingPage';
|
||||||
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';␍
|
|
||||||
␍
|
import LoginPage from '@/pages/front/LoginPage';
|
||||||
// Admin Pages␍
|
|
||||||
import AdminDashboard from '@/pages/admin/Dashboard';␍
|
import ChatPage from '@/pages/front/ChatPage';
|
||||||
import AdminUsers from '@/pages/admin/Users';␍
|
|
||||||
import AdminModels from '@/pages/admin/Models';␍
|
import ChannelPage from '@/pages/front/ChannelPage';
|
||||||
import AdminSiteSettings from '@/pages/admin/SiteSettings';␍
|
|
||||||
import AdminPayment from '@/pages/admin/Payment';␍
|
import KnowledgePage from '@/pages/front/KnowledgePage';
|
||||||
import AdminServices from '@/pages/admin/Services';␍
|
|
||||||
import AdminMembership from '@/pages/admin/Membership';␍
|
import MarketPage from '@/pages/front/MarketPage';
|
||||||
import AdminAds from '@/pages/admin/Ads';␍
|
|
||||||
import AdminMarket from '@/pages/admin/Market';␍
|
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
|
||||||
import AdminLogs from '@/pages/admin/Logs';␍
|
|
||||||
import AdminSkills from '@/pages/admin/Skills'
|
import PricingPage from '@/pages/front/PricingPage';
|
||||||
|
|
||||||
|
import ProfilePage from '@/pages/front/ProfilePage';
|
||||||
|
|
||||||
|
import NotificationPage from '@/pages/front/NotificationPage';
|
||||||
|
|
||||||
|
import AssetsPage from '@/pages/front/AssetsPage';
|
||||||
|
|
||||||
|
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
|
||||||
|
|
||||||
|
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Admin Pages
|
||||||
|
|
||||||
|
import AdminDashboard from '@/pages/admin/Dashboard';
|
||||||
|
|
||||||
|
import AdminUsers from '@/pages/admin/Users';
|
||||||
|
|
||||||
|
import AdminModels from '@/pages/admin/Models';
|
||||||
|
|
||||||
|
import AdminSiteSettings from '@/pages/admin/SiteSettings';
|
||||||
|
|
||||||
|
import AdminPayment from '@/pages/admin/Payment';
|
||||||
|
|
||||||
|
import AdminServices from '@/pages/admin/Services';
|
||||||
|
|
||||||
|
import AdminMembership from '@/pages/admin/Membership';
|
||||||
|
|
||||||
|
import AdminAds from '@/pages/admin/Ads';
|
||||||
|
|
||||||
|
import AdminMarket from '@/pages/admin/Market';
|
||||||
|
|
||||||
|
import AdminLogs from '@/pages/admin/Logs';
|
||||||
|
|
||||||
|
import AdminSkills from '@/pages/admin/Skills'
|
||||||
import AdminAgents from '@/pages/admin/Agents'
|
import AdminAgents from '@/pages/admin/Agents'
|
||||||
import AdminKnowledge from '@/pages/admin/Knowledge';;␍
|
import AdminKnowledge from '@/pages/admin/Knowledge';
|
||||||
␍
|
import AdminStorage from '@/pages/admin/Storage';;
|
||||||
const App: React.FC = () => {␍
|
|
||||||
const [user, setUser] = useState<User | null>(null);␍
|
|
||||||
const [loading, setLoading] = useState(true);␍
|
|
||||||
␍
|
const App: React.FC = () => {
|
||||||
// 初始化:从localStorage恢复登录状态␍
|
|
||||||
useEffect(() => {␍
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const token = localStorage.getItem('aiflow_token');␍
|
|
||||||
const cachedUser = localStorage.getItem('aiflow_user');␍
|
const [loading, setLoading] = useState(true);
|
||||||
if (token && cachedUser) {␍
|
|
||||||
try {␍
|
|
||||||
setUser(JSON.parse(cachedUser));␍
|
|
||||||
} catch {␍
|
// 初始化:从localStorage恢复登录状态
|
||||||
localStorage.removeItem('aiflow_token');␍
|
|
||||||
localStorage.removeItem('aiflow_user');␍
|
useEffect(() => {
|
||||||
}␍
|
|
||||||
}␍
|
const token = localStorage.getItem('aiflow_token');
|
||||||
setLoading(false);␍
|
|
||||||
}, []);␍
|
const cachedUser = localStorage.getItem('aiflow_user');
|
||||||
␍
|
|
||||||
const login = (u: User) => {␍
|
if (token && cachedUser) {
|
||||||
setUser(u);␍
|
|
||||||
localStorage.setItem('aiflow_user', JSON.stringify(u));␍
|
try {
|
||||||
};␍
|
|
||||||
const logout = () => {␍
|
setUser(JSON.parse(cachedUser));
|
||||||
setUser(null);␍
|
|
||||||
localStorage.removeItem('aiflow_token');␍
|
} catch {
|
||||||
localStorage.removeItem('aiflow_user');␍
|
|
||||||
};␍
|
localStorage.removeItem('aiflow_token');
|
||||||
const isAdmin = user?.role === 'admin';␍
|
|
||||||
␍
|
localStorage.removeItem('aiflow_user');
|
||||||
if (loading) {␍
|
|
||||||
return <div className="loading-screen">加载中...</div>;␍
|
}
|
||||||
}␍
|
|
||||||
␍
|
}
|
||||||
return (␍
|
|
||||||
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>␍
|
setLoading(false);
|
||||||
<Routes>␍
|
|
||||||
{/* Login */}␍
|
}, []);
|
||||||
<Route path="/login" element={␍
|
|
||||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />␍
|
|
||||||
} />␍
|
|
||||||
␍
|
const login = (u: User) => {
|
||||||
{/* Root - Landing for guests, redirect for authenticated users */}␍
|
|
||||||
<Route path="/" element={␍
|
setUser(u);
|
||||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />␍
|
|
||||||
} />␍
|
localStorage.setItem('aiflow_user', JSON.stringify(u));
|
||||||
␍
|
|
||||||
{/* Front Routes (authenticated only) */}␍
|
};
|
||||||
<Route path="/" element={<FrontLayout />}>␍
|
|
||||||
<Route path="chat" element={<ChatPage />} />␍
|
const logout = () => {
|
||||||
<Route path="chat/:id" element={<ChatPage />} />␍
|
|
||||||
<Route path="channels" element={<ChannelPage />} />␍
|
setUser(null);
|
||||||
<Route path="knowledge" element={<KnowledgePage />} />␍
|
|
||||||
<Route path="market" element={<MarketPage />} />␍
|
localStorage.removeItem('aiflow_token');
|
||||||
<Route path="token" element={<TokenDashboardPage />} />␍
|
|
||||||
<Route path="pricing" element={<PricingPage />} />␍
|
localStorage.removeItem('aiflow_user');
|
||||||
<Route path="profile" element={<ProfilePage />} />␍
|
|
||||||
<Route path="notifications" element={<NotificationPage />} />␍
|
};
|
||||||
<Route path="assets" element={<AssetsPage />} />␍
|
|
||||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />␍
|
const isAdmin = user?.role === 'admin';
|
||||||
<Route path="evolution" element={<EvolutionDashboard />} />␍
|
|
||||||
</Route>␍
|
|
||||||
␍
|
|
||||||
{/* Admin Routes */}␍
|
if (loading) {
|
||||||
<Route path="/admin" element={<AdminLayout />}>␍
|
|
||||||
<Route index element={<AdminDashboard />} />␍
|
return <div className="loading-screen">加载中...</div>;
|
||||||
<Route path="users" element={<AdminUsers />} />␍
|
|
||||||
<Route path="models" element={<AdminModels />} />␍
|
}
|
||||||
<Route path="settings" element={<AdminSiteSettings />} />␍
|
|
||||||
<Route path="payment" element={<AdminPayment />} />␍
|
|
||||||
<Route path="services" element={<AdminServices />} />␍
|
|
||||||
<Route path="membership" element={<AdminMembership />} />␍
|
return (
|
||||||
<Route path="ads" element={<AdminAds />} />␍
|
|
||||||
<Route path="market" element={<AdminMarket />} />␍
|
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
||||||
<Route path="logs" element={<AdminLogs />} />␍
|
|
||||||
␍
|
<Routes>
|
||||||
<Route path="skills" element={<AdminSkills />} />
|
|
||||||
<Route path="agents" element={<AdminAgents />} />
|
{/* Login */}
|
||||||
|
|
||||||
|
<Route path="/login" element={
|
||||||
|
|
||||||
|
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
|
||||||
|
|
||||||
|
} />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Root - Landing for guests, redirect for authenticated users */}
|
||||||
|
|
||||||
|
<Route path="/" element={
|
||||||
|
|
||||||
|
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
|
||||||
|
|
||||||
|
} />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Front Routes (authenticated only) */}
|
||||||
|
|
||||||
|
<Route path="/" element={<FrontLayout />}>
|
||||||
|
|
||||||
|
<Route path="chat" element={<ChatPage />} />
|
||||||
|
|
||||||
|
<Route path="chat/:id" element={<ChatPage />} />
|
||||||
|
|
||||||
|
<Route path="channels" element={<ChannelPage />} />
|
||||||
|
|
||||||
|
<Route path="knowledge" element={<KnowledgePage />} />
|
||||||
|
|
||||||
|
<Route path="market" element={<MarketPage />} />
|
||||||
|
|
||||||
|
<Route path="token" element={<TokenDashboardPage />} />
|
||||||
|
|
||||||
|
<Route path="pricing" element={<PricingPage />} />
|
||||||
|
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|
||||||
|
<Route path="notifications" element={<NotificationPage />} />
|
||||||
|
|
||||||
|
<Route path="assets" element={<AssetsPage />} />
|
||||||
|
|
||||||
|
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||||
|
|
||||||
|
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
|
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
|
||||||
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
|
||||||
|
<Route path="models" element={<AdminModels />} />
|
||||||
|
|
||||||
|
<Route path="settings" element={<AdminSiteSettings />} />
|
||||||
|
|
||||||
|
<Route path="payment" element={<AdminPayment />} />
|
||||||
|
|
||||||
|
<Route path="services" element={<AdminServices />} />
|
||||||
|
|
||||||
|
<Route path="membership" element={<AdminMembership />} />
|
||||||
|
|
||||||
|
<Route path="ads" element={<AdminAds />} />
|
||||||
|
|
||||||
|
<Route path="market" element={<AdminMarket />} />
|
||||||
|
|
||||||
|
<Route path="logs" element={<AdminLogs />} />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Route path="skills" element={<AdminSkills />} />
|
||||||
|
<Route path="agents" element={<AdminAgents />} />
|
||||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||||
</Route>␍
|
<Route path="storage" element={<AdminStorage />} />
|
||||||
␍
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" />} />␍
|
|
||||||
</Routes>␍
|
|
||||||
</AuthContext.Provider>␍
|
|
||||||
);␍
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
};␍
|
|
||||||
␍
|
</Routes>
|
||||||
export default App;␍
|
|
||||||
|
</AuthContext.Provider>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
@@ -498,6 +498,20 @@ export const adminAPI = {
|
|||||||
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
|
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
|
||||||
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
||||||
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
||||||
|
// OSS存储配置 (Phase 3.1)
|
||||||
|
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
|
||||||
|
getAdminStorageConfigs: (params?: any) => {
|
||||||
|
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||||
|
return get<any>(`/admin/storage/${query}`);
|
||||||
|
},
|
||||||
|
getAdminStorageConfig: (id: string) => get<any>(`/admin/storage/${id}`),
|
||||||
|
createAdminStorageConfig: (data: any) => post<any>('/admin/storage/', data),
|
||||||
|
updateAdminStorageConfig: (id: string, data: any) => put<any>(`/admin/storage/${id}`, data),
|
||||||
|
deleteAdminStorageConfig: (id: string) => del<any>(`/admin/storage/${id}`),
|
||||||
|
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
|
||||||
|
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
|
||||||
|
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
|
||||||
|
|
||||||
};
|
};
|
||||||
// ===== AI自进化API =====
|
// ===== AI自进化API =====
|
||||||
export const evolutionAPI = {
|
export const evolutionAPI = {
|
||||||
|
|||||||
@@ -1,128 +1,257 @@
|
|||||||
import React, { useState } from 'react';␍
|
import React, { useState } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';␍
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';␍
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import './AdminLayout.css';␍
|
|
||||||
␍
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
const adminNavItems = [␍
|
|
||||||
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },␍
|
import './AdminLayout.css';
|
||||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },␍
|
|
||||||
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },␍
|
|
||||||
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },␍
|
|
||||||
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },␍
|
const adminNavItems = [
|
||||||
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },␍
|
|
||||||
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },␍
|
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
||||||
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },␍
|
|
||||||
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },␍
|
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
||||||
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },␍
|
|
||||||
␍
|
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
|
||||||
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
|
||||||
|
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
|
||||||
|
|
||||||
|
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
|
||||||
|
|
||||||
|
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
|
||||||
|
|
||||||
|
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
|
||||||
|
|
||||||
|
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
|
||||||
|
|
||||||
|
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
|
||||||
|
|
||||||
|
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
||||||
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];␍
|
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
||||||
␍
|
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];
|
||||||
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>,␍
|
const adminIconPaths: Record<string, JSX.Element> = {
|
||||||
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>,␍
|
|
||||||
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>,␍
|
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>,
|
||||||
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>,␍
|
|
||||||
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>,␍
|
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>,
|
||||||
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>,␍
|
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>,
|
||||||
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>,␍
|
|
||||||
␍
|
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>,
|
||||||
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>,};␍
|
|
||||||
␍
|
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>,
|
||||||
const AdminLayout: React.FC = () => {␍
|
|
||||||
const { user, logout } = useAuth();␍
|
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>,
|
||||||
const navigate = useNavigate();␍
|
|
||||||
const [collapsed, setCollapsed] = useState(false);␍
|
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>,
|
||||||
␍
|
|
||||||
if (!user || user.role !== 'admin') {␍
|
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>,
|
||||||
navigate('/login');␍
|
|
||||||
return null;␍
|
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>,
|
||||||
return (␍
|
|
||||||
<div className="admin-layout">␍
|
|
||||||
{/* Admin Sidebar */}␍
|
|
||||||
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>␍
|
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>,
|
||||||
<div className="admin-sidebar-header">␍
|
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>,
|
||||||
<div className="sidebar-logo">␍
|
};
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">␍
|
|
||||||
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>␍
|
|
||||||
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>␍
|
|
||||||
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>␍
|
const AdminLayout: React.FC = () => {
|
||||||
</svg>␍
|
|
||||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}␍
|
const { user, logout } = useAuth();
|
||||||
</div>␍
|
|
||||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>␍
|
const navigate = useNavigate();
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">␍
|
|
||||||
<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"/>␍
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
</svg>␍
|
|
||||||
</button>␍
|
|
||||||
</div>␍
|
|
||||||
␍
|
if (!user || user.role !== 'admin') {
|
||||||
<nav className="admin-nav">␍
|
|
||||||
{adminNavItems.map(item => (␍
|
navigate('/login');
|
||||||
<NavLink␍
|
|
||||||
key={item.path}␍
|
return null;
|
||||||
to={item.path}␍
|
|
||||||
end={item.exact}␍
|
}
|
||||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}␍
|
|
||||||
>␍
|
|
||||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>␍
|
|
||||||
{!collapsed && <span className="nav-label">{item.label}</span>}␍
|
return (
|
||||||
</NavLink>␍
|
|
||||||
))}␍
|
<div className="admin-layout">
|
||||||
</nav>␍
|
|
||||||
␍
|
{/* Admin Sidebar */}
|
||||||
<div className="admin-sidebar-footer">␍
|
|
||||||
<NavLink to="/chat" className="admin-nav-item">␍
|
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>␍
|
<div className="admin-sidebar-header">
|
||||||
</span>␍
|
|
||||||
{!collapsed && <span className="nav-label">返回前台</span>}␍
|
<div className="sidebar-logo">
|
||||||
</NavLink>␍
|
|
||||||
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>␍
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
<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>␍
|
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
|
||||||
</span>␍
|
|
||||||
{!collapsed && <span className="nav-label">退出登录</span>}␍
|
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||||
</button>␍
|
|
||||||
</div>␍
|
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||||
{!collapsed && (␍
|
|
||||||
<div className="admin-version-info">␍
|
</svg>
|
||||||
Qihu-AIFlow v2.0.0␍
|
|
||||||
</div>␍
|
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
|
||||||
)}␍
|
|
||||||
</aside>␍
|
</div>
|
||||||
␍
|
|
||||||
{/* Admin Main Content */}␍
|
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||||
<main className="admin-main">␍
|
|
||||||
<header className="admin-topbar">␍
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<div className="admin-topbar-left">␍
|
|
||||||
<span className="admin-welcome">欢迎,{user.username}</span>␍
|
<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"/>
|
||||||
</div>␍
|
|
||||||
<div className="admin-topbar-right">␍
|
</svg>
|
||||||
<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>␍
|
</button>
|
||||||
<span>返回前台</span>␍
|
|
||||||
</a>␍
|
</div>
|
||||||
<div className="admin-user-tag">␍
|
|
||||||
<div className="user-avatar-sm admin-avatar">␍
|
|
||||||
{user.username.charAt(0).toUpperCase()}␍
|
|
||||||
</div>␍
|
<nav className="admin-nav">
|
||||||
<span>{user.username}</span>␍
|
|
||||||
</div>␍
|
{adminNavItems.map(item => (
|
||||||
</div>␍
|
|
||||||
</header>␍
|
<NavLink
|
||||||
<div className="admin-content">␍
|
|
||||||
<Outlet />␍
|
key={item.path}
|
||||||
</div>␍
|
|
||||||
</main>␍
|
to={item.path}
|
||||||
</div>␍
|
|
||||||
);␍
|
end={item.exact}
|
||||||
};␍
|
|
||||||
␍
|
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||||
export default AdminLayout;␍
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||||
|
|
||||||
|
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||||
|
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="admin-sidebar-footer">
|
||||||
|
|
||||||
|
<NavLink to="/chat" 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"><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>
|
||||||
|
|
||||||
|
{!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">
|
||||||
|
|
||||||
|
<span className="admin-welcome">欢迎,{user.username}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</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;
|
||||||
|
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
|
interface OSSConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
endpoint: string;
|
||||||
|
access_key: string;
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
base_path: string;
|
||||||
|
is_default: boolean;
|
||||||
|
is_enabled: boolean;
|
||||||
|
status: string;
|
||||||
|
config: Record<string, any>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageStats {
|
||||||
|
total: number;
|
||||||
|
enabled_count: number;
|
||||||
|
default_count: number;
|
||||||
|
by_provider: Record<string, number>;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
minio: { label: 'MinIO', color: '#C9362C', bg: '#FEF0EF' },
|
||||||
|
aliyun_oss: { label: '阿里云OSS', color: '#FF6A00', bg: '#FFF3E8' },
|
||||||
|
aws_s3: { label: 'AWS S3', color: '#FF9900', bg: '#FFF8E1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
connected: { label: '已连接', color: '#10B981', bg: '#D1FAE5' },
|
||||||
|
disconnected: { label: '未连接', color: '#6B7280', bg: '#F3F4F6' },
|
||||||
|
error: { label: '连接异常', color: '#EF4444', bg: '#FEE2E2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
const Storage: React.FC = () => {
|
||||||
|
// Data state
|
||||||
|
const [configs, setConfigs] = useState<OSSConfig[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<StorageStats | null>(null);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [filterProvider, setFilterProvider] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editingConfig, setEditingConfig] = useState<OSSConfig | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
provider: 'minio',
|
||||||
|
endpoint: '',
|
||||||
|
access_key: '',
|
||||||
|
secret_key: '',
|
||||||
|
bucket: '',
|
||||||
|
region: '',
|
||||||
|
base_path: '',
|
||||||
|
is_default: false,
|
||||||
|
is_enabled: true,
|
||||||
|
config_str: '',
|
||||||
|
});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Test connection state
|
||||||
|
const [testingId, setTestingId] = useState<string | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency_ms: number } | null>(null);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load stats
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.getAdminStorageStats() as any;
|
||||||
|
setStats(result.data || null);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load configs
|
||||||
|
const loadConfigs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = { page, page_size: PAGE_SIZE };
|
||||||
|
if (filterProvider) params.provider = filterProvider;
|
||||||
|
if (keyword) params.keyword = keyword;
|
||||||
|
const result = await adminAPI.getAdminStorageConfigs(params) as any;
|
||||||
|
const data = result.data || result;
|
||||||
|
setConfigs(data.items || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('加载失败: ' + (e.message || e), 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
}, [page, filterProvider, keyword]);
|
||||||
|
|
||||||
|
// Open create modal
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingConfig(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
provider: 'minio',
|
||||||
|
endpoint: '',
|
||||||
|
access_key: '',
|
||||||
|
secret_key: '',
|
||||||
|
bucket: '',
|
||||||
|
region: '',
|
||||||
|
base_path: '',
|
||||||
|
is_default: false,
|
||||||
|
is_enabled: true,
|
||||||
|
config_str: '',
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const openEditModal = async (config: OSSConfig) => {
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.getAdminStorageConfig(config.id) as any;
|
||||||
|
const detail = result.data;
|
||||||
|
setEditingConfig(config);
|
||||||
|
setFormData({
|
||||||
|
name: detail.name || '',
|
||||||
|
provider: detail.provider || 'minio',
|
||||||
|
endpoint: detail.endpoint || '',
|
||||||
|
access_key: '', // Not returned for security
|
||||||
|
secret_key: '', // Not returned for security
|
||||||
|
bucket: detail.bucket || '',
|
||||||
|
region: detail.region || '',
|
||||||
|
base_path: detail.base_path || '',
|
||||||
|
is_default: detail.is_default || false,
|
||||||
|
is_enabled: detail.is_enabled !== false,
|
||||||
|
config_str: detail.config ? JSON.stringify(detail.config, null, 2) : '',
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('获取配置详情失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name.trim()) { showToast('请输入配置名称', 'error'); return; }
|
||||||
|
if (!formData.endpoint.trim()) { showToast('请输入端点地址', 'error'); return; }
|
||||||
|
if (!formData.bucket.trim()) { showToast('请输入Bucket名称', 'error'); return; }
|
||||||
|
|
||||||
|
// For create, require credentials
|
||||||
|
if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) {
|
||||||
|
showToast('请输入Access Key和Secret Key', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let configObj: any = undefined;
|
||||||
|
if (formData.config_str.trim()) {
|
||||||
|
try {
|
||||||
|
configObj = JSON.parse(formData.config_str);
|
||||||
|
} catch {
|
||||||
|
showToast('扩展配置JSON格式不正确', 'error');
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
provider: formData.provider,
|
||||||
|
endpoint: formData.endpoint.trim(),
|
||||||
|
bucket: formData.bucket.trim(),
|
||||||
|
region: formData.region.trim(),
|
||||||
|
base_path: formData.base_path.trim(),
|
||||||
|
is_default: formData.is_default,
|
||||||
|
is_enabled: formData.is_enabled,
|
||||||
|
config: configObj,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include credentials if provided (for create or update)
|
||||||
|
if (formData.access_key.trim()) data.access_key = formData.access_key.trim();
|
||||||
|
if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim();
|
||||||
|
|
||||||
|
if (editingConfig) {
|
||||||
|
await adminAPI.updateAdminStorageConfig(editingConfig.id, data);
|
||||||
|
showToast('更新成功');
|
||||||
|
} else {
|
||||||
|
await adminAPI.createAdminStorageConfig(data);
|
||||||
|
showToast('创建成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
loadConfigs();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('操作失败: ' + (e.message || e), 'error');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete config
|
||||||
|
const handleDelete = async (config: OSSConfig) => {
|
||||||
|
if (!window.confirm(`确定要删除配置 "${config.name}" 吗?此操作不可恢复。`)) return;
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteAdminStorageConfig(config.id);
|
||||||
|
showToast('删除成功');
|
||||||
|
loadConfigs();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('删除失败: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle enabled
|
||||||
|
const handleToggle = async (config: OSSConfig) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.toggleAdminStorageConfig(config.id);
|
||||||
|
showToast(config.is_enabled ? '已禁用' : '已启用');
|
||||||
|
loadConfigs();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('操作失败: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set default
|
||||||
|
const handleSetDefault = async (config: OSSConfig) => {
|
||||||
|
if (config.is_default) return;
|
||||||
|
try {
|
||||||
|
await adminAPI.setDefaultAdminStorageConfig(config.id);
|
||||||
|
showToast(`已将 "${config.name}" 设为默认存储`);
|
||||||
|
loadConfigs();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('操作失败: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
const handleTestConnection = async (config: OSSConfig) => {
|
||||||
|
setTestingId(config.id);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.testAdminStorageConfig({ config_id: config.id }) as any;
|
||||||
|
const data = result.data;
|
||||||
|
setTestResult(data);
|
||||||
|
showToast(data.success ? '连接测试成功' : '连接测试失败', data.success ? 'success' : 'error');
|
||||||
|
loadConfigs();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('测试失败: ' + (e.message || e), 'error');
|
||||||
|
} finally {
|
||||||
|
setTestingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<div className="admin-page-header">
|
||||||
|
<h2>OSS存储配置管理</h2>
|
||||||
|
<button className="btn btn-primary" onClick={openCreateModal}>+ 添加存储配置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{stats.total}</div>
|
||||||
|
<div className="stat-label">总配置数</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_count}</div>
|
||||||
|
<div className="stat-label">已启用</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.default_count}</div>
|
||||||
|
<div className="stat-label">默认存储</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label" style={{ marginTop: '8px' }}>
|
||||||
|
{Object.entries(stats.by_status || {}).map(([s, c]) => (
|
||||||
|
<span key={s} style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
margin: '0 4px',
|
||||||
|
color: (STATUS_CONFIG[s] || {}).color || '#666',
|
||||||
|
background: (STATUS_CONFIG[s] || {}).bg || '#f0f0f0',
|
||||||
|
}}>
|
||||||
|
{(STATUS_CONFIG[s] || {}).label || s}: {c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="stat-label">连接状态</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
value={filterProvider}
|
||||||
|
onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
{Object.entries(PROVIDER_CONFIG).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索名称/端点/Bucket..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<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: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>配置名称</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>提供商</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>端点地址</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>Bucket</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>状态</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>默认</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '240px' }}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无存储配置,点击"添加存储配置"开始</td></tr>
|
||||||
|
) : (
|
||||||
|
configs.map(cfg => (
|
||||||
|
<tr key={cfg.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontWeight: 500 }}>{cfg.name}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: (PROVIDER_CONFIG[cfg.provider] || {}).color || '#666',
|
||||||
|
background: (PROVIDER_CONFIG[cfg.provider] || {}).bg || '#f0f0f0',
|
||||||
|
}}>
|
||||||
|
{(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cfg.endpoint}>
|
||||||
|
{cfg.endpoint}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace' }}>{cfg.bucket}</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: (STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).color,
|
||||||
|
background: (STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).bg,
|
||||||
|
}}>
|
||||||
|
{(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||||
|
{cfg.is_default ? (
|
||||||
|
<span style={{ color: '#6C63FF', fontSize: '13px', fontWeight: 600 }}>默认</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefault(cfg)}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
background: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6B7280',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
设为默认
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestConnection(cfg)}
|
||||||
|
disabled={testingId === cfg.id}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
background: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#374151',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testingId === cfg.id ? '测试中...' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(cfg)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
background: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: cfg.is_enabled ? '#EF4444' : '#10B981',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cfg.is_enabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(cfg)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
background: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#374151',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(cfg)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #FEE2E2',
|
||||||
|
background: '#FEF2F2',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#EF4444',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{modalVisible && (
|
||||||
|
<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,
|
||||||
|
}}>
|
||||||
|
<div className="modal-content" style={{
|
||||||
|
background: '#fff', borderRadius: '12px', padding: '24px',
|
||||||
|
width: '560px', maxHeight: '90vh', overflow: 'auto',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>
|
||||||
|
{editingConfig ? '编辑存储配置' : '添加存储配置'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>配置名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="如: 生产环境MinIO"
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>存储提供商</label>
|
||||||
|
<select
|
||||||
|
value={formData.provider}
|
||||||
|
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="aliyun_oss">阿里云OSS</option>
|
||||||
|
<option value="aws_s3">AWS S3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endpoint */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>端点地址 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.endpoint}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
||||||
|
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : formData.provider === 'aliyun_oss' ? 'https://oss-cn-hangzhou.aliyuncs.com' : 'https://s3.amazonaws.com'}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Access Key & Secret Key */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||||
|
Access Key {!editingConfig ? '*' : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.access_key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
||||||
|
placeholder={editingConfig ? '留空则不修改' : '输入Access Key'}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||||
|
Secret Key {!editingConfig ? '*' : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.secret_key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||||
|
placeholder={editingConfig ? '留空则不修改' : '输入Secret Key'}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket & Region */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>Bucket *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.bucket}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bucket: e.target.value })}
|
||||||
|
placeholder="my-bucket"
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>区域 (Region)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.region}
|
||||||
|
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||||
|
placeholder="如: us-east-1"
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base Path */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>基础路径 (Base Path)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.base_path}
|
||||||
|
onChange={(e) => setFormData({ ...formData, base_path: e.target.value })}
|
||||||
|
placeholder="如: aipaaS/uploads/"
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkboxes */}
|
||||||
|
<div style={{ display: 'flex', gap: '20px' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||||
|
/>
|
||||||
|
设为默认存储
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_enabled}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Config */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||||
|
扩展配置 (JSON, 可选)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.config_str}
|
||||||
|
onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
|
||||||
|
placeholder='{"use_ssl": true, "timeout": 30}'
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalVisible(false)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||||
|
background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px', borderRadius: '6px', border: 'none',
|
||||||
|
background: '#6C63FF', cursor: submitting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px', color: '#fff', opacity: submitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? '保存中...' : (editingConfig ? '保存修改' : '创建配置')}
|
||||||
|
</button>
|
||||||
|
</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 Storage;
|
||||||
Reference in new issue
Block a user