P1+P2 complete: pgvector, Casdoor SSO, CLI, KB router, K8s, monitoring, UI fixes (Promotion rewrite, Storage/Automation btn fix, membership configs mojibake fix)
This commit is contained in:
1 parent
1f47951895
commit
177e345539
7 files changed
+1385
-890
No files matched your search
@@ -0,0 +1,214 @@
|
|||||||
|
"""
|
||||||
|
Casdoor SSO 集成模块
|
||||||
|
==================
|
||||||
|
实现 Casdoor OIDC 单点登录集成,
|
||||||
|
支持 JWT + Casdoor 双认证模式。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
from app.casdoor_auth import casdoor_router, get_casdoor_login_url, handle_casdoor_callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
from .models import User, gen_uuid
|
||||||
|
from .auth import create_access_token, hash_password
|
||||||
|
|
||||||
|
logger = logging.getLogger("aiflow.casdoor")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Casdoor 配置
|
||||||
|
CASDOOR_ENDPOINT = os.environ.get("CASDOOR_ENDPOINT", "http://casdoor:8000")
|
||||||
|
CASDOOR_CLIENT_ID = os.environ.get("CASDOOR_CLIENT_ID", "aiflow")
|
||||||
|
CASDOOR_CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "")
|
||||||
|
CASDOOR_ORG_NAME = os.environ.get("CASDOOR_ORG_NAME", "aiflow")
|
||||||
|
CASDOOR_APP_NAME = os.environ.get("CASDOOR_APP_NAME", "aiflow-app")
|
||||||
|
CASDOOR_REDIRECT_URI = os.environ.get(
|
||||||
|
"CASDOOR_REDIRECT_URI",
|
||||||
|
"http://210.16.161.218:12888/app/api/auth/casdoor/callback"
|
||||||
|
)
|
||||||
|
APP_FRONTEND_URL = os.environ.get("APP_ACCESS_URL", "http://210.16.161.218:12888")
|
||||||
|
|
||||||
|
|
||||||
|
def get_casdoor_login_url(state: str = "") -> str:
|
||||||
|
"""获取 Casdoor 登录跳转URL"""
|
||||||
|
params = {
|
||||||
|
"client_id": CASDOOR_CLIENT_ID,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": CASDOOR_REDIRECT_URI,
|
||||||
|
"scope": "openid profile email",
|
||||||
|
"state": state or "aiflow",
|
||||||
|
}
|
||||||
|
return f"{CASDOOR_ENDPOINT}/login/oauth/authorize?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code_for_token(code: str) -> Optional[dict]:
|
||||||
|
"""用授权码换取 access_token 和用户信息"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
# 获取 token
|
||||||
|
token_resp = await client.post(
|
||||||
|
f"{CASDOOR_ENDPOINT}/api/login/oauth/access_token",
|
||||||
|
json={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": CASDOOR_CLIENT_ID,
|
||||||
|
"client_secret": CASDOOR_CLIENT_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": CASDOOR_REDIRECT_URI,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
logger.error(f"Casdoor token exchange failed: {token_resp.status_code} {token_resp.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = token_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
logger.error("No access_token in Casdoor response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
user_resp = await client.get(
|
||||||
|
f"{CASDOOR_ENDPOINT}/api/account",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_resp.status_code != 200:
|
||||||
|
# 尝试备用端点
|
||||||
|
user_resp = await client.get(
|
||||||
|
f"{CASDOOR_ENDPOINT}/api/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_resp.status_code != 200:
|
||||||
|
logger.error(f"Casdoor userinfo failed: {user_resp.status_code}")
|
||||||
|
return {"access_token": access_token, "user": None}
|
||||||
|
|
||||||
|
user_info = user_resp.json()
|
||||||
|
return {"access_token": access_token, "user": user_info}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Casdoor token exchange error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_user(db: Session, casdoor_user: dict) -> User:
|
||||||
|
"""根据 Casdoor 用户信息获取或创建本地用户"""
|
||||||
|
casdoor_id = casdoor_user.get("id", casdoor_user.get("name", ""))
|
||||||
|
casdoor_name = casdoor_user.get("name", casdoor_user.get("preferred_username", ""))
|
||||||
|
casdoor_email = casdoor_user.get("email", "")
|
||||||
|
casdoor_display = casdoor_user.get("displayName", casdoor_user.get("name", ""))
|
||||||
|
|
||||||
|
# 通过 Casdoor ID 查找已有用户
|
||||||
|
existing = db.query(User).filter(User.casdoor_id == casdoor_id).first() if hasattr(User, "casdoor_id") else None
|
||||||
|
|
||||||
|
# 通过用户名查找
|
||||||
|
if not existing and casdoor_name:
|
||||||
|
existing = db.query(User).filter(User.username == f"sso_{casdoor_name}").first()
|
||||||
|
if existing and hasattr(existing, "casdoor_id"):
|
||||||
|
existing.casdoor_id = casdoor_id
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 更新最后登录时间
|
||||||
|
existing.last_login = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# 创建新用户
|
||||||
|
username = f"sso_{casdoor_name}" if casdoor_name else f"sso_{casdoor_id[:8]}"
|
||||||
|
# 避免用户名冲突
|
||||||
|
base_username = username
|
||||||
|
counter = 1
|
||||||
|
while db.query(User).filter(User.username == username).first():
|
||||||
|
username = f"{base_username}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
new_user = User(
|
||||||
|
id=gen_uuid(),
|
||||||
|
username=username,
|
||||||
|
email=casdoor_email or None,
|
||||||
|
password_hash=hash_password(casdoor_id + datetime.now().isoformat()), # 随机密码
|
||||||
|
role="user",
|
||||||
|
status="active",
|
||||||
|
display_name=casdoor_display or username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果 User 模型有 casdoor_id 字段则设置
|
||||||
|
if hasattr(new_user, "casdoor_id"):
|
||||||
|
new_user.casdoor_id = casdoor_id
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
logger.info(f"Created SSO user: {username} (casdoor_id={casdoor_id})")
|
||||||
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API 路由 ====================
|
||||||
|
|
||||||
|
@router.get("/casdoor/login")
|
||||||
|
async def casdoor_login(state: str = ""):
|
||||||
|
"""跳转到 Casdoor 登录页面"""
|
||||||
|
login_url = get_casdoor_login_url(state)
|
||||||
|
return RedirectResponse(url=login_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/casdoor/callback")
|
||||||
|
async def casdoor_callback(
|
||||||
|
code: str,
|
||||||
|
state: str = "",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Casdoor OAuth 回调"""
|
||||||
|
result = await exchange_code_for_token(code)
|
||||||
|
if not result or not result.get("user"):
|
||||||
|
# 重定向到前端登录页,带错误参数
|
||||||
|
return RedirectResponse(url=f"{APP_FRONTEND_URL}/#/login?error=casdoor_failed")
|
||||||
|
|
||||||
|
casdoor_user = result["user"]
|
||||||
|
user = await get_or_create_user(db, casdoor_user)
|
||||||
|
|
||||||
|
# 生成平台 JWT token
|
||||||
|
token_data = {"sub": user.id, "username": user.username, "role": user.role}
|
||||||
|
access_token = create_access_token(token_data)
|
||||||
|
|
||||||
|
# 重定向到前端,带 token
|
||||||
|
redirect_url = f"{APP_FRONTEND_URL}/#/login?token={access_token}&sso=casdoor"
|
||||||
|
return RedirectResponse(url=redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/casdoor/status")
|
||||||
|
async def casdoor_status():
|
||||||
|
"""检查 Casdoor SSO 是否可用"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.get(f"{CASDOOR_ENDPOINT}/api/health")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return {"enabled": True, "endpoint": CASDOOR_ENDPOINT}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"enabled": False, "endpoint": CASDOOR_ENDPOINT}
|
||||||
|
|
||||||
|
|
||||||
|
# 配置说明:
|
||||||
|
# 1. 在 Casdoor 中创建应用,获取 Client ID 和 Client Secret
|
||||||
|
# 2. 设置环境变量:
|
||||||
|
# - CASDOOR_ENDPOINT=http://casdoor:8000
|
||||||
|
# - CASDOOR_CLIENT_ID=your_client_id
|
||||||
|
# - CASDOOR_CLIENT_SECRET=your_client_secret
|
||||||
|
# - CASDOOR_REDIRECT_URI=http://your-domain/app/api/auth/casdoor/callback
|
||||||
|
# 3. 在 User 模型中添加 casdoor_id 字段 (可选):
|
||||||
|
# casdoor_id = Column(String(128), unique=True, nullable=True)
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
知识库路由层
|
||||||
|
============
|
||||||
|
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(FastGPT/Dify/RAGFlow)。
|
||||||
|
根据知识库配置自动选择最优检索策略。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
from app.kb_router import kb_router_search
|
||||||
|
results = await kb_router_search(query, kb_id, top_k=5)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("aiflow.kb_router")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
|
||||||
|
"""获取已配置的外部KB服务"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
try:
|
||||||
|
query = "SELECT * FROM external_kb_services"
|
||||||
|
if enabled_only:
|
||||||
|
query += " WHERE is_enabled = 1"
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
result = db_session.execute(text(query))
|
||||||
|
columns = result.keys()
|
||||||
|
return [dict(zip(columns, row)) for row in result.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get external services: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_fastgpt(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
|
"""搜索 FastGPT 知识库"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{api_url.rstrip('/')}/api/v1/dataset/search",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"similarity": 0.5,
|
||||||
|
"limit": top_k,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
for item in data.get("data", {}).get("list", []):
|
||||||
|
results.append({
|
||||||
|
"content": item.get("q", item.get("content", "")),
|
||||||
|
"score": float(item.get("score", 0.5)),
|
||||||
|
"source": f"FastGPT: {item.get('collectionId', '')}",
|
||||||
|
"metadata": item,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
else:
|
||||||
|
logger.warning(f"FastGPT search returned {resp.status_code}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"FastGPT search error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_dify(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
|
"""搜索 Dify 知识库"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{api_url.rstrip('/')}/v1/datasets/retrieval",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"retrieval_mode": "semantic",
|
||||||
|
"top_k": top_k,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
for item in data.get("records", []):
|
||||||
|
segment = item.get("segment", {})
|
||||||
|
results.append({
|
||||||
|
"content": segment.get("content", ""),
|
||||||
|
"score": float(item.get("score", 0.5)),
|
||||||
|
"source": f"Dify: {segment.get('id', '')}",
|
||||||
|
"metadata": item,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
else:
|
||||||
|
logger.warning(f"Dify search returned {resp.status_code}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Dify search error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_ragflow(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
|
"""搜索 RAGFlow 知识库"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{api_url.rstrip('/')}/api/v1/retrieval",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"question": query,
|
||||||
|
"top_k": top_k,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
for item in data.get("data", {}).get("chunks", []):
|
||||||
|
results.append({
|
||||||
|
"content": item.get("content", ""),
|
||||||
|
"score": float(item.get("similarity", 0.5)),
|
||||||
|
"source": f"RAGFlow: {item.get('document_id', '')}",
|
||||||
|
"metadata": item,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
else:
|
||||||
|
logger.warning(f"RAGFlow search returned {resp.status_code}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RAGFlow search error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
|
||||||
|
"""路由到对应的外部KB服务"""
|
||||||
|
service_type = service.get("service_type", "")
|
||||||
|
api_url = service.get("api_url", "")
|
||||||
|
api_key = service.get("api_key", "")
|
||||||
|
|
||||||
|
if service_type == "fastgpt":
|
||||||
|
return await _search_fastgpt(api_url, api_key, query, top_k)
|
||||||
|
elif service_type == "dify":
|
||||||
|
return await _search_dify(api_url, api_key, query, top_k)
|
||||||
|
elif service_type == "ragflow":
|
||||||
|
return await _search_ragflow(api_url, api_key, query, top_k)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown external KB type: {service_type}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def kb_router_search(
|
||||||
|
query: str,
|
||||||
|
kb_id: Optional[str] = None,
|
||||||
|
top_k: int = 5,
|
||||||
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
|
db_session=None,
|
||||||
|
use_vector: bool = True,
|
||||||
|
use_external: bool = True,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
知识库路由层 — 混合检索
|
||||||
|
|
||||||
|
检索策略:
|
||||||
|
1. 本地 BM25 检索 (MySQL, 已有)
|
||||||
|
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
|
||||||
|
3. 外部KB检索 (FastGPT/Dify/RAGFlow, 按配置)
|
||||||
|
|
||||||
|
融合方式: Reciprocal Rank Fusion (RRF)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 查询文本
|
||||||
|
kb_id: 知识库ID
|
||||||
|
top_k: 返回结果数
|
||||||
|
bm25_results: BM25检索结果
|
||||||
|
db_session: 数据库会话(用于获取外部KB配置)
|
||||||
|
use_vector: 是否使用向量检索
|
||||||
|
use_external: 是否使用外部KB检索
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
融合后的检索结果列表
|
||||||
|
"""
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
# 1. BM25 结果
|
||||||
|
if bm25_results:
|
||||||
|
for rank, item in enumerate(bm25_results):
|
||||||
|
all_results.append({
|
||||||
|
"content": item.get("content", item.get("text", "")),
|
||||||
|
"score": item.get("score", 1.0 / (60 + rank + 1)),
|
||||||
|
"source": "local_bm25",
|
||||||
|
"rank": rank + 1,
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 向量检索
|
||||||
|
if use_vector:
|
||||||
|
try:
|
||||||
|
from app.pgvector_search import vector_search
|
||||||
|
vec_results = await vector_search(query, kb_id, top_k)
|
||||||
|
for rank, item in enumerate(vec_results):
|
||||||
|
all_results.append({
|
||||||
|
"content": item.get("content", ""),
|
||||||
|
"score": item.get("score", 0.5),
|
||||||
|
"source": "vector_pg",
|
||||||
|
"rank": rank + 1,
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
})
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("pgvector_search not available, skipping vector search")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Vector search failed: {e}")
|
||||||
|
|
||||||
|
# 3. 外部KB检索
|
||||||
|
if use_external and db_session:
|
||||||
|
services = await _get_external_services(db_session)
|
||||||
|
for service in services:
|
||||||
|
if service.get("status") == "connected":
|
||||||
|
ext_results = await _search_external(service, query, top_k)
|
||||||
|
for rank, item in enumerate(ext_results):
|
||||||
|
all_results.append({
|
||||||
|
"content": item.get("content", ""),
|
||||||
|
"score": item.get("score", 0.5),
|
||||||
|
"source": item.get("source", f"external_{service['service_type']}"),
|
||||||
|
"rank": rank + 1,
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not all_results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# RRF 融合
|
||||||
|
rrf_k = 60
|
||||||
|
score_map = {}
|
||||||
|
for item in all_results:
|
||||||
|
content_key = item["content"][:200] # 用内容前200字符作为去重键
|
||||||
|
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
|
||||||
|
|
||||||
|
if content_key in score_map:
|
||||||
|
score_map[content_key]["score"] += rrf_score
|
||||||
|
score_map[content_key]["sources"].append(item["source"])
|
||||||
|
else:
|
||||||
|
score_map[content_key] = {
|
||||||
|
"content": item["content"],
|
||||||
|
"score": rrf_score,
|
||||||
|
"sources": [item["source"]],
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 按融合分数排序
|
||||||
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
|
# 标准化分数到 0-1
|
||||||
|
if merged:
|
||||||
|
max_score = merged[0]["score"]
|
||||||
|
for item in merged:
|
||||||
|
item["score"] = item["score"] / max_score if max_score > 0 else 0
|
||||||
|
item["sources"] = list(set(item["sources"]))
|
||||||
|
|
||||||
|
return merged[:top_k]
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""
|
||||||
|
PGVector 向量检索模块
|
||||||
|
====================
|
||||||
|
使用 PostgreSQL + pgvector 实现语义向量检索,
|
||||||
|
与现有 MySQL BM25 检索配合,实现混合检索。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
from app.pgvector_search import vector_search
|
||||||
|
results = await vector_search(query, kb_id, top_k=5)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger("aiflow.pgvector")
|
||||||
|
|
||||||
|
# PostgreSQL 连接配置
|
||||||
|
PG_HOST = os.environ.get("POSTGRES_HOST", "pgvector")
|
||||||
|
PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
|
||||||
|
PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
|
||||||
|
PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
|
||||||
|
PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
|
||||||
|
|
||||||
|
# 嵌入模型配置
|
||||||
|
EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
|
||||||
|
EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
|
||||||
|
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
|
||||||
|
|
||||||
|
# 连接池
|
||||||
|
_pg_pool = None
|
||||||
|
_pool_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pg_pool():
|
||||||
|
"""获取 PostgreSQL 异步连接池"""
|
||||||
|
global _pg_pool
|
||||||
|
if _pg_pool is not None:
|
||||||
|
return _pg_pool
|
||||||
|
|
||||||
|
async with _pool_lock:
|
||||||
|
if _pg_pool is not None:
|
||||||
|
return _pg_pool
|
||||||
|
|
||||||
|
try:
|
||||||
|
import asyncpg
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("asyncpg not installed, vector search disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_pg_pool = await asyncpg.create_pool(
|
||||||
|
host=PG_HOST,
|
||||||
|
port=PG_PORT,
|
||||||
|
user=PG_USER,
|
||||||
|
password=PG_PASSWORD,
|
||||||
|
database=PG_DB,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10,
|
||||||
|
command_timeout=30,
|
||||||
|
)
|
||||||
|
logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
|
||||||
|
|
||||||
|
# 初始化表结构
|
||||||
|
async with _pg_pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS document_embeddings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(64) NOT NULL,
|
||||||
|
kb_id VARCHAR(64) NOT NULL,
|
||||||
|
chunk_id VARCHAR(64),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding vector(1024),
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_kb
|
||||||
|
ON document_embeddings(kb_id);
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_doc
|
||||||
|
ON document_embeddings(doc_id);
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
||||||
|
ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
""")
|
||||||
|
logger.info("PGVector tables initialized")
|
||||||
|
|
||||||
|
return _pg_pool
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
||||||
|
_pg_pool = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embedding(text: str) -> Optional[List[float]]:
|
||||||
|
"""调用嵌入API获取文本向量"""
|
||||||
|
if not EMBEDDING_API_KEY:
|
||||||
|
logger.warning("No embedding API key configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{EMBEDDING_API_URL}/v1/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {EMBEDDING_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": EMBEDDING_MODEL,
|
||||||
|
"input": text[:8000], # 截断防止超长
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
embedding = data["data"][0]["embedding"]
|
||||||
|
return embedding
|
||||||
|
else:
|
||||||
|
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Embedding generation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def vector_search(
|
||||||
|
query: str,
|
||||||
|
kb_id: Optional[str] = None,
|
||||||
|
top_k: int = 5,
|
||||||
|
threshold: float = 0.5,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
向量语义检索
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 查询文本
|
||||||
|
kb_id: 知识库ID (可选,不指定则搜索全部)
|
||||||
|
top_k: 返回结果数
|
||||||
|
threshold: 相似度阈值 (0-1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
|
||||||
|
"""
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if pool is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 获取查询向量
|
||||||
|
query_embedding = await get_embedding(query)
|
||||||
|
if query_embedding is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# 将向量转换为 PostgreSQL 格式
|
||||||
|
embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
|
||||||
|
|
||||||
|
if kb_id:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
|
1 - (embedding <=> $1::vector) as score
|
||||||
|
FROM document_embeddings
|
||||||
|
WHERE kb_id = $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $3;
|
||||||
|
""",
|
||||||
|
embedding_str, kb_id, top_k,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
|
1 - (embedding <=> $1::vector) as score
|
||||||
|
FROM document_embeddings
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $2;
|
||||||
|
""",
|
||||||
|
embedding_str, top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
score = float(row["score"])
|
||||||
|
if score >= threshold:
|
||||||
|
results.append({
|
||||||
|
"doc_id": row["doc_id"],
|
||||||
|
"chunk_id": row["chunk_id"],
|
||||||
|
"content": row["content"],
|
||||||
|
"score": score,
|
||||||
|
"metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Vector search failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def index_document(
|
||||||
|
doc_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
content: str,
|
||||||
|
chunk_id: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
将文档内容索引到 PGVector
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_id: 文档ID
|
||||||
|
kb_id: 知识库ID
|
||||||
|
content: 文档内容
|
||||||
|
chunk_id: 分块ID (可选)
|
||||||
|
metadata: 元数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if pool is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 分块处理(简单按长度分块)
|
||||||
|
chunks = []
|
||||||
|
chunk_size = 500
|
||||||
|
overlap = 50
|
||||||
|
if len(content) <= chunk_size:
|
||||||
|
chunks = [(content, chunk_id or f"{doc_id}_0")]
|
||||||
|
else:
|
||||||
|
for i in range(0, len(content), chunk_size - overlap):
|
||||||
|
chunk = content[i:i + chunk_size]
|
||||||
|
if len(chunk.strip()) > 10:
|
||||||
|
chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for chunk_text, cid in chunks:
|
||||||
|
embedding = await get_embedding(chunk_text)
|
||||||
|
if embedding is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
||||||
|
import json
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::vector, $6)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
""",
|
||||||
|
doc_id, kb_id, cid, chunk_text, embedding_str,
|
||||||
|
json.dumps(metadata or {}),
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to index chunk {cid}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
|
||||||
|
return success_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def hybrid_search(
|
||||||
|
query: str,
|
||||||
|
kb_id: Optional[str] = None,
|
||||||
|
top_k: int = 5,
|
||||||
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
混合检索:BM25 + 向量检索融合
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 查询文本
|
||||||
|
kb_id: 知识库ID
|
||||||
|
top_k: 返回结果数
|
||||||
|
bm25_results: BM25 检索结果(来自MySQL)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
融合后的检索结果
|
||||||
|
"""
|
||||||
|
# 获取向量检索结果
|
||||||
|
vec_results = await vector_search(query, kb_id, top_k)
|
||||||
|
|
||||||
|
if not vec_results and not bm25_results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not vec_results:
|
||||||
|
return bm25_results or []
|
||||||
|
|
||||||
|
if not bm25_results:
|
||||||
|
return vec_results
|
||||||
|
|
||||||
|
# 融合:Reciprocal Rank Fusion (RRF)
|
||||||
|
rrf_k = 60
|
||||||
|
score_map = {}
|
||||||
|
|
||||||
|
for rank, item in enumerate(bm25_results):
|
||||||
|
doc_id = item.get("doc_id", item.get("id", ""))
|
||||||
|
score_map[doc_id] = {
|
||||||
|
"content": item.get("content", item.get("text", "")),
|
||||||
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
|
"bm25_rank": rank + 1,
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for rank, item in enumerate(vec_results):
|
||||||
|
doc_id = item["doc_id"]
|
||||||
|
if doc_id in score_map:
|
||||||
|
score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
|
||||||
|
score_map[doc_id]["vec_score"] = item["score"]
|
||||||
|
score_map[doc_id]["vec_rank"] = rank + 1
|
||||||
|
else:
|
||||||
|
score_map[doc_id] = {
|
||||||
|
"content": item["content"],
|
||||||
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
|
"vec_score": item["score"],
|
||||||
|
"vec_rank": rank + 1,
|
||||||
|
"metadata": item.get("metadata", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 按融合分数排序
|
||||||
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
|
return merged[:top_k]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_document_embeddings(doc_id: str) -> bool:
|
||||||
|
"""删除文档的所有向量索引"""
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if pool is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM document_embeddings WHERE doc_id = $1;",
|
||||||
|
doc_id,
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted embeddings for document {doc_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete embeddings: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool():
|
||||||
|
"""关闭连接池"""
|
||||||
|
global _pg_pool
|
||||||
|
if _pg_pool is not None:
|
||||||
|
await _pg_pool.close()
|
||||||
|
_pg_pool = None
|
||||||
|
logger.info("PostgreSQL connection pool closed")
|
||||||
@@ -19,6 +19,7 @@ import logging
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.casdoor_auth import router as casdoor_router
|
||||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||||
|
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
|
||||||
@@ -443,8 +445,27 @@ app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=[
|
|||||||
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
|
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
|
||||||
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
||||||
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||||
|
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
async def prometheus_metrics():
|
||||||
|
"""Prometheus metrics endpoint"""
|
||||||
|
import psutil
|
||||||
|
metrics = []
|
||||||
|
try:
|
||||||
|
metrics.append(f"aiflow_cpu_percent {psutil.cpu_percent()}")
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
metrics.append(f"aiflow_memory_used_bytes {mem.used}")
|
||||||
|
metrics.append(f"aiflow_memory_total_bytes {mem.total}")
|
||||||
|
metrics.append(f"aiflow_memory_percent {mem.percent}")
|
||||||
|
disk = psutil.disk_usage('/')
|
||||||
|
metrics.append(f"aiflow_disk_used_bytes {disk.used}")
|
||||||
|
metrics.append(f"aiflow_disk_total_bytes {disk.total}")
|
||||||
|
metrics.append(f"aiflow_process_count {len(psutil.pids())}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return PlainTextResponse("\n".join(metrics), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/app/api/health")
|
@app.get("/app/api/health")
|
||||||
|
|||||||
@@ -309,9 +309,9 @@ const Automation: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<div className="admin-page-header">
|
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
|
||||||
<h2>自动化任务调度</h2>
|
<h2>自动化任务调度</h2>
|
||||||
<button className="btn btn-primary" onClick={openCreateModal}>+ 创建任务</button>
|
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -364,7 +364,7 @@ const Automation: React.FC = () => {
|
|||||||
<th style={thStyle}>上次执行</th>
|
<th style={thStyle}>上次执行</th>
|
||||||
<th style={thStyle}>下次执行</th>
|
<th style={thStyle}>下次执行</th>
|
||||||
<th style={thStyle}>成功率</th>
|
<th style={thStyle}>成功率</th>
|
||||||
<th style={{ ...thStyle, textAlign: 'right', width: '260px' }}>操作</th>
|
<th style={{ ...thStyle, textAlign: 'right', width: '320px' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -402,7 +402,7 @@ const Automation: React.FC = () => {
|
|||||||
: <span style={{ color: '#9CA3AF' }}>-</span>}
|
: <span style={{ color: '#9CA3AF' }}>-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
|
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
|
||||||
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
|
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
|
||||||
{executingId === task.id ? '执行中...' : '执行'}
|
{executingId === task.id ? '执行中...' : '执行'}
|
||||||
@@ -457,7 +457,7 @@ const Automation: React.FC = () => {
|
|||||||
<div><label style={labelStyle}>触发方式</label>
|
<div><label style={labelStyle}>触发方式</label>
|
||||||
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
|
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
|
||||||
<option value="cron">定时触发 (Cron)</option>
|
<option value="cron">定时触发 (Cron)</option>
|
||||||
<option value="webhook">Webhook</option>
|
<option value="webhook">Webhook回调</option>
|
||||||
<option value="event">事件触发</option>
|
<option value="event">事件触发</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label style={labelStyle}>操作类型</label>
|
<div><label style={labelStyle}>操作类型</label>
|
||||||
@@ -591,7 +591,7 @@ const badgeStyle = (cfg: { color: string; bg: string }) => ({
|
|||||||
color: cfg.color, background: cfg.bg,
|
color: cfg.color, background: cfg.bg,
|
||||||
} as React.CSSProperties);
|
} as React.CSSProperties);
|
||||||
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
|
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
|
||||||
padding: '4px 10px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
|
padding: '5px 12px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
|
||||||
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
|
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
|
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -93,9 +93,9 @@ const PROVIDER_CONFIG: Record<string, { label: string; color: string; bg: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
connected: { label: 'Connected', color: '#10B981', bg: 'rgba(16,185,129,0.15)' },
|
connected: { label: '已连接', color: '#10B981', bg: 'rgba(16,185,129,0.15)' },
|
||||||
disconnected: { label: 'Offline', color: '#6B7280', bg: 'rgba(107,114,128,0.15)' },
|
disconnected: { label: '离线', color: '#6B7280', bg: 'rgba(107,114,128,0.15)' },
|
||||||
error: { label: 'Error', color: '#EF4444', bg: 'rgba(239,68,68,0.15)' },
|
error: { label: '错误', color: '#EF4444', bg: 'rgba(239,68,68,0.15)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
@@ -209,9 +209,9 @@ const Storage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.name.trim()) { showToast('Name required', 'error'); return; }
|
if (!formData.name.trim()) { showToast('请输入名称', 'error'); return; }
|
||||||
if (!formData.endpoint.trim()) { showToast('Endpoint required', 'error'); return; }
|
if (!formData.endpoint.trim()) { showToast('请输入端点', 'error'); return; }
|
||||||
if (!formData.bucket.trim()) { showToast('Bucket required', 'error'); return; }
|
if (!formData.bucket.trim()) { showToast('请输入Bucket', 'error'); return; }
|
||||||
if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) {
|
if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) {
|
||||||
showToast('Access Key and Secret Key required', 'error'); return;
|
showToast('Access Key and Secret Key required', 'error'); return;
|
||||||
}
|
}
|
||||||
@@ -238,12 +238,12 @@ const Storage: React.FC = () => {
|
|||||||
|
|
||||||
const handleDelete = async (config: OSSConfig) => {
|
const handleDelete = async (config: OSSConfig) => {
|
||||||
if (!window.confirm(`Delete "${config.name}"?`)) return;
|
if (!window.confirm(`Delete "${config.name}"?`)) return;
|
||||||
try { await adminAPI.deleteAdminStorageConfig(config.id); showToast('Deleted'); loadConfigs(); loadStats(); }
|
try { await adminAPI.deleteAdminStorageConfig(config.id); showToast('已删除'); loadConfigs(); loadStats(); }
|
||||||
catch (e: any) { showToast('Delete failed', 'error'); }
|
catch (e: any) { showToast('删除失败', 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (config: OSSConfig) => {
|
const handleToggle = async (config: OSSConfig) => {
|
||||||
try { await adminAPI.toggleAdminStorageConfig(config.id); showToast(config.is_enabled ? 'Disabled' : 'Enabled'); loadConfigs(); loadStats(); }
|
try { await adminAPI.toggleAdminStorageConfig(config.id); showToast(config.is_enabled ? '已禁用' : '已启用'); loadConfigs(); loadStats(); }
|
||||||
catch (e: any) { showToast('Failed', 'error'); }
|
catch (e: any) { showToast('Failed', 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ const Storage: React.FC = () => {
|
|||||||
const data = result.data;
|
const data = result.data;
|
||||||
showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error');
|
showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error');
|
||||||
loadConfigs(); loadStats();
|
loadConfigs(); loadStats();
|
||||||
} catch (e: any) { showToast('Test failed', 'error'); }
|
} catch (e: any) { showToast('测试失败', 'error'); }
|
||||||
finally { setTestingId(null); }
|
finally { setTestingId(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,13 +347,13 @@ const Storage: React.FC = () => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<div className="admin-page-header">
|
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
|
||||||
<h2>Storage Management</h2>
|
<h2>存储管理</h2>
|
||||||
{activeTab === 'oss' && (
|
{activeTab === 'oss' && (
|
||||||
<button className="btn btn-primary" onClick={openCreateModal}>+ Add Storage</button>
|
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ Add Storage</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'quota' && (
|
{activeTab === 'quota' && (
|
||||||
<button className="btn btn-primary" onClick={handleInitAllQuotas}>Init All Quotas</button>
|
<button className="admin-btn admin-btn-primary" onClick={handleInitAllQuotas}>初始化所有配额</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -364,7 +364,7 @@ const Storage: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
padding: '10px 24px', border: 'none', borderBottom: '2px solid transparent',
|
padding: '10px 24px', border: 'none', borderBottom: '2px solid transparent',
|
||||||
background: 'transparent', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
background: 'transparent', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
||||||
color: activeTab === 'oss' ? '#6C63FF' : 'rgba(255,255,255,0.5)',
|
color: activeTab === 'oss' ? '#6C63FF' : 'rgba(0,0,0,0.5)',
|
||||||
borderBottomColor: activeTab === 'oss' ? '#6C63FF' : 'transparent',
|
borderBottomColor: activeTab === 'oss' ? '#6C63FF' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -375,7 +375,7 @@ const Storage: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
padding: '10px 24px', border: 'none', borderBottom: '2px solid transparent',
|
padding: '10px 24px', border: 'none', borderBottom: '2px solid transparent',
|
||||||
background: 'transparent', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
background: 'transparent', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
|
||||||
color: activeTab === 'quota' ? '#6C63FF' : 'rgba(255,255,255,0.5)',
|
color: activeTab === 'quota' ? '#6C63FF' : 'rgba(0,0,0,0.5)',
|
||||||
borderBottomColor: activeTab === 'quota' ? '#6C63FF' : 'transparent',
|
borderBottomColor: activeTab === 'quota' ? '#6C63FF' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -392,15 +392,15 @@ const Storage: React.FC = () => {
|
|||||||
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<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-card">
|
||||||
<div className="stat-value">{stats.total}</div>
|
<div className="stat-value">{stats.total}</div>
|
||||||
<div className="stat-label">Total Configs</div>
|
<div className="stat-label">总配置数</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_count}</div>
|
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_count}</div>
|
||||||
<div className="stat-label">Enabled</div>
|
<div className="stat-label">已启用</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.default_count}</div>
|
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.default_count}</div>
|
||||||
<div className="stat-label">Default</div>
|
<div className="stat-label">默认</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label" style={{ marginTop: '8px' }}>
|
<div className="stat-label" style={{ marginTop: '8px' }}>
|
||||||
@@ -409,78 +409,78 @@ const Storage: React.FC = () => {
|
|||||||
display: 'inline-block', padding: '2px 10px', borderRadius: '12px',
|
display: 'inline-block', padding: '2px 10px', borderRadius: '12px',
|
||||||
fontSize: '12px', margin: '0 4px',
|
fontSize: '12px', margin: '0 4px',
|
||||||
color: (STATUS_CONFIG[s] || {}).color || '#999',
|
color: (STATUS_CONFIG[s] || {}).color || '#999',
|
||||||
background: (STATUS_CONFIG[s] || {}).bg || 'rgba(255,255,255,0.05)',
|
background: (STATUS_CONFIG[s] || {}).bg || 'rgba(0,0,0,0.06)',
|
||||||
}}>
|
}}>
|
||||||
{(STATUS_CONFIG[s] || {}).label || s}: {c}
|
{(STATUS_CONFIG[s] || {}).label || s}: {c}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">Status</div>
|
<div className="stat-label">状态</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||||
<select value={filterProvider} onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }}
|
<select value={filterProvider} onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px' }}>
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
|
||||||
<option value="">All Types</option>
|
<option value="">全部类型</option>
|
||||||
{Object.entries(PROVIDER_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(PROVIDER_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder="Search..." value={keyword}
|
<input type="text" placeholder="Search..." value={keyword}
|
||||||
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-container" style={{ background: '#FFFFFF', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
<div className="table-container" style={{ background: '#FFFFFF', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'rgba(255,255,255,0.03)', borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Name</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>名称</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Provider</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>服务商</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Endpoint</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>端点</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Bucket</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>存储桶</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Status</th>
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>状态</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Default</th>
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>默认</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600, width: '280px' }}>Actions</th>
|
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600, width: '320px' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>Loading...</td></tr>
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: 'rgba(0,0,0,0.3)' }}>加载中...</td></tr>
|
||||||
) : configs.length === 0 ? (
|
) : configs.length === 0 ? (
|
||||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>No storage configs</td></tr>
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: 'rgba(0,0,0,0.3)' }}>暂无存储配置</td></tr>
|
||||||
) : (
|
) : (
|
||||||
configs.map(cfg => (
|
configs.map(cfg => (
|
||||||
<tr key={cfg.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
<tr key={cfg.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#fff' }}>{cfg.name}</td>
|
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#F8F9FC' }}>{cfg.name}</td>
|
||||||
<td style={{ padding: '12px 16px' }}>
|
<td style={{ padding: '12px 16px' }}>
|
||||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', color: (PROVIDER_CONFIG[cfg.provider] || {}).color || '#999', background: (PROVIDER_CONFIG[cfg.provider] || {}).bg || 'rgba(255,255,255,0.05)' }}>
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', color: (PROVIDER_CONFIG[cfg.provider] || {}).color || '#999', background: (PROVIDER_CONFIG[cfg.provider] || {}).bg || 'rgba(0,0,0,0.06)' }}>
|
||||||
{(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider}
|
{(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: 'rgba(255,255,255,0.5)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cfg.endpoint}>{cfg.endpoint}</td>
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: 'rgba(0,0,0,0.5)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cfg.endpoint}>{cfg.endpoint}</td>
|
||||||
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace', color: 'rgba(255,255,255,0.7)' }}>{cfg.bucket}</td>
|
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace', color: 'rgba(0,0,0,0.7)' }}>{cfg.bucket}</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
<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 }}>
|
<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}
|
{(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||||
{cfg.is_default ? <span style={{ color: '#6C63FF', fontSize: '13px', fontWeight: 600 }}>Default</span> :
|
{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 rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(255,255,255,0.5)' }}>Set Default</button>}
|
<button onClick={() => handleSetDefault(cfg)} style={{ padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(0,0,0,0.5)' }}>设为默认</button>}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => handleTestConnection(cfg)} disabled={testingId === cfg.id}
|
<button onClick={() => handleTestConnection(cfg)} disabled={testingId === cfg.id}
|
||||||
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(255,255,255,0.7)' }}>
|
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(0,0,0,0.7)' }}>
|
||||||
{testingId === cfg.id ? '...' : 'Test'}
|
{testingId === cfg.id ? '...' : '测试'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleToggle(cfg)}
|
<button onClick={() => handleToggle(cfg)}
|
||||||
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: cfg.is_enabled ? '#EF4444' : '#10B981' }}>
|
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: cfg.is_enabled ? '#EF4444' : '#10B981' }}>
|
||||||
{cfg.is_enabled ? 'Disable' : 'Enable'}
|
{cfg.is_enabled ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => openEditModal(cfg)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(255,255,255,0.7)' }}>Edit</button>
|
<button onClick={() => openEditModal(cfg)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(0,0,0,0.7)' }}>编辑</button>
|
||||||
<button onClick={() => handleDelete(cfg)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.1)', cursor: 'pointer', fontSize: '12px', color: '#EF4444' }}>Delete</button>
|
<button onClick={() => handleDelete(cfg)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.1)', cursor: 'pointer', fontSize: '12px', color: '#EF4444' }}>删除</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -493,10 +493,10 @@ const Storage: React.FC = () => {
|
|||||||
{total > PAGE_SIZE && (
|
{total > PAGE_SIZE && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#fff' }}>Prev</button>
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#F8F9FC' }}>上一页</button>
|
||||||
<span style={{ padding: '6px 12px', color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>Page {page} / {Math.ceil(total / PAGE_SIZE)} ({total})</span>
|
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>Page {page} / {Math.ceil(total / PAGE_SIZE)} ({total})</span>
|
||||||
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
|
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
|
||||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#fff' }}>Next</button>
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#F8F9FC' }}>下一页</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -513,23 +513,23 @@ const Storage: React.FC = () => {
|
|||||||
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value">{quotaOverview.total_users}</div>
|
<div className="stat-value">{quotaOverview.total_users}</div>
|
||||||
<div className="stat-label">Total Users</div>
|
<div className="stat-label">总用户数</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: '#6C63FF' }}>{quotaOverview.quota_users}</div>
|
<div className="stat-value" style={{ color: '#6C63FF' }}>{quotaOverview.quota_users}</div>
|
||||||
<div className="stat-label">With Quota</div>
|
<div className="stat-label">有配额</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: '#10B981' }}>{formatBytes(quotaOverview.total_quota_bytes)}</div>
|
<div className="stat-value" style={{ color: '#10B981' }}>{formatBytes(quotaOverview.total_quota_bytes)}</div>
|
||||||
<div className="stat-label">Total Quota</div>
|
<div className="stat-label">总配额</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: '#F59E0B' }}>{formatBytes(quotaOverview.total_used_bytes)}</div>
|
<div className="stat-value" style={{ color: '#F59E0B' }}>{formatBytes(quotaOverview.total_used_bytes)}</div>
|
||||||
<div className="stat-label">Total Used</div>
|
<div className="stat-label">总使用量</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ color: quotaOverview.over_quota_count > 0 ? '#EF4444' : '#10B981' }}>{quotaOverview.over_quota_count}</div>
|
<div className="stat-value" style={{ color: quotaOverview.over_quota_count > 0 ? '#EF4444' : '#10B981' }}>{quotaOverview.over_quota_count}</div>
|
||||||
<div className="stat-label">Over Quota</div>
|
<div className="stat-label">超额使用</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ const Storage: React.FC = () => {
|
|||||||
{Object.entries(quotaOverview.default_quotas || {}).map(([level, bytes]) => (
|
{Object.entries(quotaOverview.default_quotas || {}).map(([level, bytes]) => (
|
||||||
<div key={level} style={{ padding: '16px', borderRadius: '12px', background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.08)' }}>
|
<div key={level} style={{ padding: '16px', borderRadius: '12px', background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>
|
<span style={{ fontSize: '14px', fontWeight: 600, color: '#F8F9FC' }}>
|
||||||
{(quotaOverview.tier_labels || {})[level] || `Level ${level}`}
|
{(quotaOverview.tier_labels || {})[level] || `Level ${level}`}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>
|
||||||
@@ -546,7 +546,7 @@ const Storage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '24px', fontWeight: 700, color: '#6C63FF', marginBottom: '4px' }}>{formatBytes(bytes)}</div>
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#6C63FF', marginBottom: '4px' }}>{formatBytes(bytes)}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)' }}>
|
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
|
||||||
{(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} users | Used: ${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : 'No users'}
|
{(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} users | Used: ${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : 'No users'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -558,41 +558,41 @@ const Storage: React.FC = () => {
|
|||||||
{/* Filter Bar */}
|
{/* Filter Bar */}
|
||||||
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||||
<select value={quotaLevelFilter} onChange={(e) => { setQuotaLevelFilter(e.target.value === '' ? '' : parseInt(e.target.value)); setQuotaPage(1); }}
|
<select value={quotaLevelFilter} onChange={(e) => { setQuotaLevelFilter(e.target.value === '' ? '' : parseInt(e.target.value)); setQuotaPage(1); }}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px' }}>
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
|
||||||
<option value="">All Tiers</option>
|
<option value="">全部等级</option>
|
||||||
<option value="0">Free</option>
|
<option value="0">免费版</option>
|
||||||
<option value="2">Pro</option>
|
<option value="2">专业版</option>
|
||||||
<option value="3">Enterprise</option>
|
<option value="3">企业版</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder="Search username/email..." value={quotaKeyword}
|
<input type="text" placeholder="Search username/email..." value={quotaKeyword}
|
||||||
onChange={(e) => { setQuotaKeyword(e.target.value); setQuotaPage(1); }}
|
onChange={(e) => { setQuotaKeyword(e.target.value); setQuotaPage(1); }}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quota Users Table */}
|
{/* Quota Users Table */}
|
||||||
<div className="table-container" style={{ background: '#FFFFFF', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
<div className="table-container" style={{ background: '#FFFFFF', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'rgba(255,255,255,0.03)', borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>User</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>用户</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Tier</th>
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>等级</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600, width: '300px' }}>Usage</th>
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600, width: '300px' }}>用量</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Used / Quota</th>
|
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>已用 / 配额</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>Type</th>
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600 }}>类型</th>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontWeight: 600, width: '200px' }}>Actions</th>
|
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontWeight: 600, width: '240px' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{quotaLoading ? (
|
{quotaLoading ? (
|
||||||
<tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>Loading...</td></tr>
|
<tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: 'rgba(0,0,0,0.3)' }}>加载中...</td></tr>
|
||||||
) : quotaUsers.length === 0 ? (
|
) : quotaUsers.length === 0 ? (
|
||||||
<tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>No users found. Click "Init All Quotas" to initialize.</td></tr>
|
<tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: 'rgba(0,0,0,0.3)' }}>暂无用户数据,请点击"初始化配额"开始</td></tr>
|
||||||
) : (
|
) : (
|
||||||
quotaUsers.map(u => (
|
quotaUsers.map(u => (
|
||||||
<tr key={u.user_id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
<tr key={u.user_id} style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
<td style={{ padding: '12px 16px' }}>
|
<td style={{ padding: '12px 16px' }}>
|
||||||
<div style={{ fontWeight: 500, color: '#fff' }}>{u.username}</div>
|
<div style={{ fontWeight: 500, color: '#F8F9FC' }}>{u.username}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)' }}>{u.email || '-'}</div>
|
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>{u.email || '-'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||||
@@ -602,7 +602,7 @@ const Storage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px' }}>
|
<td style={{ padding: '12px 16px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: `${Math.min(u.usage_percent, 100)}%`, height: '100%', borderRadius: '4px',
|
width: `${Math.min(u.usage_percent, 100)}%`, height: '100%', borderRadius: '4px',
|
||||||
background: u.is_over_quota ? '#EF4444' : u.usage_percent > 80 ? '#F59E0B' : '#10B981',
|
background: u.is_over_quota ? '#EF4444' : u.usage_percent > 80 ? '#F59E0B' : '#10B981',
|
||||||
@@ -615,7 +615,7 @@ const Storage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', fontFamily: 'monospace', color: 'rgba(255,255,255,0.7)' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', fontFamily: 'monospace', color: 'rgba(0,0,0,0.7)' }}>
|
||||||
{formatBytes(u.used_bytes)} / {formatBytes(u.quota_bytes)}
|
{formatBytes(u.used_bytes)} / {formatBytes(u.quota_bytes)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||||
@@ -626,9 +626,9 @@ const Storage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => handleViewDetail(u.user_id)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(255,255,255,0.7)' }}>Detail</button>
|
<button onClick={() => handleViewDetail(u.user_id)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(0,0,0,0.7)' }}>详情</button>
|
||||||
<button onClick={() => openAdjustModal(u)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(108,99,255,0.3)', background: 'rgba(108,99,255,0.1)', cursor: 'pointer', fontSize: '12px', color: '#6C63FF' }}>Adjust</button>
|
<button onClick={() => openAdjustModal(u)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(108,99,255,0.3)', background: 'rgba(108,99,255,0.1)', cursor: 'pointer', fontSize: '12px', color: '#6C63FF' }}>调整</button>
|
||||||
<button onClick={() => handleRecalcUser(u.user_id)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(255,255,255,0.7)' }}>Recalc</button>
|
<button onClick={() => handleRecalcUser(u.user_id)} style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: 'rgba(0,0,0,0.7)' }}>重新计算</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -641,10 +641,10 @@ const Storage: React.FC = () => {
|
|||||||
{quotaTotal > PAGE_SIZE && (
|
{quotaTotal > PAGE_SIZE && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||||
<button disabled={quotaPage <= 1} onClick={() => setQuotaPage(p => p - 1)}
|
<button disabled={quotaPage <= 1} onClick={() => setQuotaPage(p => p - 1)}
|
||||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage <= 1 ? 'not-allowed' : 'pointer', opacity: quotaPage <= 1 ? 0.5 : 1, color: '#fff' }}>Prev</button>
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage <= 1 ? 'not-allowed' : 'pointer', opacity: quotaPage <= 1 ? 0.5 : 1, color: '#F8F9FC' }}>上一页</button>
|
||||||
<span style={{ padding: '6px 12px', color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>Page {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} ({quotaTotal})</span>
|
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} 页 ({quotaTotal})</span>
|
||||||
<button disabled={quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE)} onClick={() => setQuotaPage(p => p + 1)}
|
<button disabled={quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE)} onClick={() => setQuotaPage(p => p + 1)}
|
||||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 0.5 : 1, color: '#fff' }}>Next</button>
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 0.5 : 1, color: '#F8F9FC' }}>下一页</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -664,84 +664,84 @@ const Storage: React.FC = () => {
|
|||||||
width: '560px', maxHeight: '90vh', overflow: 'auto',
|
width: '560px', maxHeight: '90vh', overflow: 'auto',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', border: '1px solid rgba(0,0,0,0.08)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', border: '1px solid rgba(0,0,0,0.08)',
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#fff' }}>
|
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}>
|
||||||
{editingConfig ? 'Edit Storage Config' : 'Add Storage Config'}
|
{editingConfig ? '编辑存储配置' : '新建存储配置'}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Name *</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>名称 *</label>
|
||||||
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="e.g. Production MinIO"
|
placeholder="e.g. Production MinIO"
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Provider</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>提供商</label>
|
||||||
<select value={formData.provider} onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
<select value={formData.provider} onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }}>
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }}>
|
||||||
<option value="minio">MinIO</option>
|
<option value="minio">MinIO</option>
|
||||||
<option value="aliyun_oss">Aliyun OSS</option>
|
<option value="aliyun_oss">Aliyun OSS</option>
|
||||||
<option value="aws_s3">AWS S3</option>
|
<option value="aws_s3">AWS S3</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Endpoint *</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>端点 *</label>
|
||||||
<input type="text" value={formData.endpoint} onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
<input type="text" value={formData.endpoint} onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
||||||
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : 'https://oss-cn-hangzhou.aliyuncs.com'}
|
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : 'https://oss-cn-hangzhou.aliyuncs.com'}
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Access Key {!editingConfig ? '*' : ''}</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Access Key {!editingConfig ? '*' : ''}</label>
|
||||||
<input type="text" value={formData.access_key} onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
<input type="text" value={formData.access_key} onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
||||||
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Access Key'}
|
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Access Key'}
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Secret Key {!editingConfig ? '*' : ''}</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Secret Key {!editingConfig ? '*' : ''}</label>
|
||||||
<input type="password" value={formData.secret_key} onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
<input type="password" value={formData.secret_key} onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||||
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Secret Key'}
|
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Secret Key'}
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Bucket *</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Bucket *</label>
|
||||||
<input type="text" value={formData.bucket} onChange={(e) => setFormData({ ...formData, bucket: e.target.value })}
|
<input type="text" value={formData.bucket} onChange={(e) => setFormData({ ...formData, bucket: e.target.value })}
|
||||||
placeholder="my-bucket"
|
placeholder="my-bucket"
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Region</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>区域</label>
|
||||||
<input type="text" value={formData.region} onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
<input type="text" value={formData.region} onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||||
placeholder="us-east-1"
|
placeholder="us-east-1"
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Base Path</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>基础路径</label>
|
||||||
<input type="text" value={formData.base_path} onChange={(e) => setFormData({ ...formData, base_path: e.target.value })}
|
<input type="text" value={formData.base_path} onChange={(e) => setFormData({ ...formData, base_path: e.target.value })}
|
||||||
placeholder="aipaaS/uploads/"
|
placeholder="aipaaS/uploads/"
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '20px' }}>
|
<div style={{ display: 'flex', gap: '20px' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
|
||||||
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> Set as default
|
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> Set as default
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
|
||||||
<input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> Enabled
|
<input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> Enabled
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>Extra Config (JSON, optional)</label>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Extra Config (JSON, optional)</label>
|
||||||
<textarea value={formData.config_str} onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
|
<textarea value={formData.config_str} onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
|
||||||
placeholder='{"use_ssl": true, "timeout": 30}' rows={3}
|
placeholder='{"use_ssl": true, "timeout": 30}' rows={3}
|
||||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||||
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(255,255,255,0.7)' }}>Cancel</button>
|
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(0,0,0,0.7)' }}>取消</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 }}>
|
<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 ? 'Saving...' : (editingConfig ? 'Save' : 'Create')}
|
{submitting ? '保存中...' : (editingConfig ? '保存' : '创建')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,13 +763,13 @@ const Storage: React.FC = () => {
|
|||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', border: '1px solid rgba(0,0,0,0.08)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', border: '1px solid rgba(0,0,0,0.08)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#fff' }}>Storage Detail</h3>
|
<h3 style={{ margin: 0, fontSize: '18px', color: '#F8F9FC' }}>存储详情</h3>
|
||||||
<button onClick={() => setQuotaDetail(null)} style={{ background: 'transparent', border: 'none', color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '20px' }}>x</button>
|
<button onClick={() => setQuotaDetail(null)} style={{ background: 'transparent', border: 'none', color: 'rgba(0,0,0,0.5)', cursor: 'pointer', fontSize: '20px' }}>x</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 700, color: '#fff' }}>{quotaDetail.user.username}</div>
|
<div style={{ fontSize: '20px', fontWeight: 700, color: '#F8F9FC' }}>{quotaDetail.user.username}</div>
|
||||||
<div style={{ fontSize: '13px', color: 'rgba(255,255,255,0.4)' }}>{quotaDetail.user.email || '-'}</div>
|
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.4)' }}>{quotaDetail.user.email || '-'}</div>
|
||||||
<span style={{ display: 'inline-block', marginTop: '4px', padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
<span style={{ display: 'inline-block', marginTop: '4px', padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||||
background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>{quotaDetail.user.tier_label}</span>
|
background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>{quotaDetail.user.tier_label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,49 +777,49 @@ const Storage: React.FC = () => {
|
|||||||
{quotaDetail.quota && (
|
{quotaDetail.quota && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
<span style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)' }}>Usage</span>
|
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)' }}>使用量</span>
|
||||||
<span style={{ fontSize: '13px', color: '#fff', fontFamily: 'monospace' }}>
|
<span style={{ fontSize: '13px', color: '#fff', fontFamily: 'monospace' }}>
|
||||||
{formatBytes(quotaDetail.quota.used_bytes)} / {formatBytes(quotaDetail.quota.quota_bytes)}
|
{formatBytes(quotaDetail.quota.used_bytes)} / {formatBytes(quotaDetail.quota.quota_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: '10px', borderRadius: '5px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
<div style={{ height: '10px', borderRadius: '5px', background: 'rgba(0,0,0,0.08)', overflow: 'hidden' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: `${Math.min(quotaDetail.quota.quota_bytes > 0 ? (quotaDetail.quota.used_bytes / quotaDetail.quota.quota_bytes * 100) : 0, 100)}%`,
|
width: `${Math.min(quotaDetail.quota.quota_bytes > 0 ? (quotaDetail.quota.used_bytes / quotaDetail.quota.quota_bytes * 100) : 0, 100)}%`,
|
||||||
height: '100%', borderRadius: '5px',
|
height: '100%', borderRadius: '5px',
|
||||||
background: quotaDetail.quota.used_bytes >= quotaDetail.quota.quota_bytes ? '#EF4444' : '#10B981',
|
background: quotaDetail.quota.used_bytes >= quotaDetail.quota.quota_bytes ? '#EF4444' : '#10B981',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right', marginTop: '4px', fontSize: '12px', color: 'rgba(255,255,255,0.4)' }}>
|
<div style={{ textAlign: 'right', marginTop: '4px', fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
|
||||||
{quotaDetail.quota.quota_bytes > 0 ? (quotaDetail.quota.used_bytes / quotaDetail.quota.quota_bytes * 100).toFixed(1) : 0}%
|
{quotaDetail.quota.quota_bytes > 0 ? (quotaDetail.quota.used_bytes / quotaDetail.quota.quota_bytes * 100).toFixed(1) : 0}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '20px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '20px' }}>
|
||||||
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.03)' }}>
|
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(0,0,0,0.03)' }}>
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', marginBottom: '4px' }}>Files</div>
|
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)', marginBottom: '4px' }}>文件数</div>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 700, color: '#fff' }}>{quotaDetail.file_count}</div>
|
<div style={{ fontSize: '20px', fontWeight: 700, color: '#fff' }}>{quotaDetail.file_count}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.03)' }}>
|
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(0,0,0,0.03)' }}>
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', marginBottom: '4px' }}>Quota Type</div>
|
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)', marginBottom: '4px' }}>配额类型</div>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 700, color: '#6C63FF' }}>{quotaDetail.quota?.quota_type || 'N/A'}</div>
|
<div style={{ fontSize: '20px', fontWeight: 700, color: '#6C63FF' }}>{quotaDetail.quota?.quota_type || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Object.keys(quotaDetail.file_type_stats || {}).length > 0 && (
|
{Object.keys(quotaDetail.file_type_stats || {}).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '8px', fontWeight: 600 }}>By File Type</div>
|
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', marginBottom: '8px', fontWeight: 600 }}>By File Type</div>
|
||||||
{Object.entries(quotaDetail.file_type_stats).map(([ftype, stat]) => (
|
{Object.entries(quotaDetail.file_type_stats).map(([ftype, stat]) => (
|
||||||
<div key={ftype} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 12px', borderRadius: '6px', background: 'rgba(255,255,255,0.03)', marginBottom: '4px' }}>
|
<div key={ftype} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 12px', borderRadius: '6px', background: 'rgba(0,0,0,0.03)', marginBottom: '4px' }}>
|
||||||
<span style={{ fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>{ftype}</span>
|
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.7)' }}>{ftype}</span>
|
||||||
<span style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', fontFamily: 'monospace' }}>{stat.count} files / {formatBytes(stat.total_bytes)}</span>
|
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontFamily: 'monospace' }}>{stat.count} files / {formatBytes(stat.total_bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||||
<button onClick={() => setQuotaDetail(null)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(255,255,255,0.7)' }}>Close</button>
|
<button onClick={() => setQuotaDetail(null)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(0,0,0,0.7)' }}>关闭</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -839,34 +839,34 @@ const Storage: React.FC = () => {
|
|||||||
width: '420px', boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
width: '420px', boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||||
border: '1px solid rgba(0,0,0,0.08)',
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#fff' }}>Adjust Quota</h3>
|
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}>调整配额</h3>
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<div style={{ fontSize: '16px', fontWeight: 600, color: '#fff' }}>{adjustModal.username}</div>
|
<div style={{ fontSize: '16px', fontWeight: 600, color: '#fff' }}>{adjustModal.username}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)' }}>
|
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
|
||||||
Current: {formatBytes(adjustModal.quota_bytes)} | Used: {formatBytes(adjustModal.used_bytes)}
|
Current: {formatBytes(adjustModal.quota_bytes)} | Used: {formatBytes(adjustModal.used_bytes)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '13px', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>New Quota Value</label>
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>新配额值</label>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<input type="number" value={adjustValue} onChange={(e) => setAdjustValue(e.target.value)}
|
<input type="number" value={adjustValue} onChange={(e) => setAdjustValue(e.target.value)}
|
||||||
step="0.1" min="0"
|
step="0.1" min="0"
|
||||||
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px' }} />
|
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }} />
|
||||||
<select value={adjustUnit} onChange={(e) => setAdjustUnit(e.target.value as 'GB' | 'MB')}
|
<select value={adjustUnit} onChange={(e) => setAdjustUnit(e.target.value as 'GB' | 'MB')}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#fff', fontSize: '14px' }}>
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
|
||||||
<option value="GB">GB</option>
|
<option value="GB">GB</option>
|
||||||
<option value="MB">MB</option>
|
<option value="MB">MB</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'rgba(255,255,255,0.4)' }}>
|
<div style={{ marginTop: '8px', fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
|
||||||
= {adjustValue && !isNaN(parseFloat(adjustValue)) ? formatBytes((adjustUnit === 'GB' ? parseFloat(adjustValue) * 1024 * 1024 * 1024 : parseFloat(adjustValue) * 1024 * 1024)) : '0 B'}
|
= {adjustValue && !isNaN(parseFloat(adjustValue)) ? formatBytes((adjustUnit === 'GB' ? parseFloat(adjustValue) * 1024 * 1024 * 1024 : parseFloat(adjustValue) * 1024 * 1024)) : '0 B'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||||
<button onClick={() => setAdjustModal(null)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(255,255,255,0.7)' }}>Cancel</button>
|
<button onClick={() => setAdjustModal(null)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'rgba(0,0,0,0.7)' }}>取消</button>
|
||||||
<button onClick={handleAdjustSubmit} disabled={adjusting}
|
<button onClick={handleAdjustSubmit} disabled={adjusting}
|
||||||
style={{ padding: '8px 20px', borderRadius: '6px', border: 'none', background: '#6C63FF', cursor: adjusting ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff', opacity: adjusting ? 0.7 : 1 }}>
|
style={{ padding: '8px 20px', borderRadius: '6px', border: 'none', background: '#6C63FF', cursor: adjusting ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff', opacity: adjusting ? 0.7 : 1 }}>
|
||||||
{adjusting ? 'Saving...' : 'Confirm'}
|
{adjusting ? '保存中...' : '确认'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user