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:
qyyfw committed 2026-07-03 14:45:10 +08:00
1 parent 5d1ad26874
commit b2ba75a666
7 files changed
+1889 -92

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": "连接测试完成"
}
+19
View File
@@ -979,3 +979,22 @@ class ExternalKBService(Base):
status = Column(String(20), default="disconnected")
created_at = Column(DateTime, default=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)
+216 -1
View File
@@ -1,217 +1,432 @@
"""
Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀
"""
import sys
import os
import time
import uuid
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin
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
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转)
access_logger = setup_file_logging()
logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
start_time = time.time()
path = request.url.path
method = request.method
# 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths)
try:
response = await call_next(request)
elapsed = time.time() - start_time
status_code = response.status_code
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
)
# 异步记录API调用统计
try:
db = SessionLocal()
try:
_record_api_call(db, method, path, status_code, elapsed)
finally:
db.close()
except Exception as e:
logger.warning(f"API统计记录失败: {e}")
return response
except Exception as e:
elapsed = time.time() - start_time
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
)
raise
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID)
parts = path.split("/")
simplified_parts = []
for part in parts:
# 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}")
# 将纯数字路径段替换为{id}
elif part.isdigit():
simplified_parts.append("{id}")
else:
simplified_parts.append(part)
simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow:
logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
)
@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(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"}
+126 -1
View File
@@ -1,128 +1,253 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts
import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// Front Pages
import LandingPage from '@/pages/front/LandingPage';
import LoginPage from '@/pages/front/LoginPage';
import ChatPage from '@/pages/front/ChatPage';
import ChannelPage from '@/pages/front/ChannelPage';
import KnowledgePage from '@/pages/front/KnowledgePage';
import MarketPage from '@/pages/front/MarketPage';
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
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 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);
// 初始化:从localStorage恢复登录状态
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
if (token && cachedUser) {
try {
setUser(JSON.parse(cachedUser));
} catch {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
}
}
setLoading(false);
}, []);
const login = (u: User) => {
setUser(u);
localStorage.setItem('aiflow_user', JSON.stringify(u));
};
const logout = () => {
setUser(null);
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Routes>
{/* 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="storage" element={<AdminStorage />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthContext.Provider>
);
};
export default App;
+14
View File
@@ -498,6 +498,20 @@ export const adminAPI = {
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
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 =====
export const evolutionAPI = {
+219 -90
View File
@@ -1,128 +1,257 @@
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ 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/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>,
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>,
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>,
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
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>,};␍
const AdminLayout: React.FC = () => {␍
const { user, logout } = useAuth();␍
const navigate = useNavigate();␍
const [collapsed, setCollapsed] = useState(false);␍
if (!user || user.role !== 'admin') {␍
navigate('/login');␍
return null;␍
}␍
␍
return (␍
<div className="admin-layout">␍
{/* Admin Sidebar */}␍
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>␍
<div className="admin-sidebar-header">␍
<div className="sidebar-logo">␍
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">␍
<rect width="32" height="32" rx="8" fill="#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"/>␍
</svg>␍
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}␍
</div>␍
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>␍
<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"/>␍
</svg>␍
</button>␍
</div>␍
␍
<nav className="admin-nav">␍
{adminNavItems.map(item => (␍
<NavLink␍
key={item.path}␍
to={item.path}␍
end={item.exact}␍
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}␍
>␍
<span className="nav-icon">{adminIconPaths[item.icon]}</span>␍
{!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>␍
);␍
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>,
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>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user || user.role !== 'admin') {
navigate('/login');
return null;
}
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#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"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<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"/>
</svg>
</button>
</div>
<nav className="admin-nav">
{adminNavItems.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
{!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;
+713
View File
@@ -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;