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:
qyyfw committed 2026-07-08 12:10:32 +08:00
1 parent 1f47951895
commit 177e345539
7 files changed
+1385 -890

No files matched your search

+214
View File
@@ -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)
+267
View File
@@ -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]
+360
View File
@@ -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")
+21
View File
@@ -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")
+6 -6
View File
@@ -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
+129 -129
View File
@@ -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>