diff --git a/backend/python/app/api/admin/storage_admin.py b/backend/python/app/api/admin/storage_admin.py index 12dfc93..f5da3b1 100644 --- a/backend/python/app/api/admin/storage_admin.py +++ b/backend/python/app/api/admin/storage_admin.py @@ -104,6 +104,328 @@ def config_to_dict(config) -> dict: return result + + +# ============================================================ +# Storage Quota Management (Phase C5) +# ============================================================ + +DEFAULT_QUOTAS = { + 0: 1073741824, # Free: 1GB + 2: 10737418240, # Pro: 10GB + 3: 107374182400, # Enterprise: 100GB +} + +TIER_LABELS = {0: "\u514d\u8d39\u7248", 2: "\u4e13\u4e1a\u7248", 3: "\u4f01\u4e1a\u7248"} + + +class QuotaAdjustRequest(BaseModel): + quota_bytes: Optional[int] = Field(None, ge=0) + quota_type: Optional[str] = Field(None, max_length=20) + + +def ensure_user_quota(user_id: str, membership_level: int, plan_id: str = None, db: Session = None) -> dict: + from ...models import UserStorageQuota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + default_bytes = DEFAULT_QUOTAS.get(membership_level, DEFAULT_QUOTAS[0]) + if not quota: + quota = UserStorageQuota( + id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes, + used_bytes=0, quota_type="membership", + membership_level=membership_level, plan_id=plan_id, + ) + db.add(quota) + db.commit() + db.refresh(quota) + elif quota.quota_type == "membership" and quota.membership_level != membership_level: + quota.quota_bytes = default_bytes + quota.membership_level = membership_level + quota.plan_id = plan_id + db.commit() + db.refresh(quota) + return { + "id": quota.id, "user_id": quota.user_id, + "quota_bytes": quota.quota_bytes, "used_bytes": quota.used_bytes, + "quota_type": quota.quota_type, "membership_level": quota.membership_level, + "plan_id": quota.plan_id, + "created_at": quota.created_at.isoformat() if quota.created_at else None, + "updated_at": quota.updated_at.isoformat() if quota.updated_at else None, + } + + +def recalculate_storage_usage(user_id: str, db: Session) -> int: + from ...models import KnowledgeDocument, KnowledgeBase + total = db.query(func.coalesce(func.sum(KnowledgeDocument.file_size), 0)).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter( + KnowledgeBase.user_id == user_id, + ).scalar() or 0 + return int(total) + + +@router.get("/quota/overview", summary="Storage Quota Overview") +async def get_quota_overview( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + total_quota_users = db.query(func.count(UserStorageQuota.id)).scalar() or 0 + total_quota_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0)).scalar() or 0 + total_used_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.used_bytes), 0)).scalar() or 0 + total_users = db.query(func.count(User.id)).filter(User.status == "active").scalar() or 0 + users_without_quota = total_users - total_quota_users + by_level = {} + level_stats = db.query( + UserStorageQuota.membership_level, + func.count(UserStorageQuota.id), + func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0), + func.coalesce(func.sum(UserStorageQuota.used_bytes), 0), + ).group_by(UserStorageQuota.membership_level).all() + for level, count, total_q, total_u in level_stats: + by_level[str(level)] = { + "label": TIER_LABELS.get(level, f"Level {level}"), + "user_count": count, + "total_quota_bytes": int(total_q), + "total_used_bytes": int(total_u), + } + over_quota_count = db.query(func.count(UserStorageQuota.id)).filter( + UserStorageQuota.quota_bytes > 0, + UserStorageQuota.used_bytes > UserStorageQuota.quota_bytes + ).scalar() or 0 + return { + "code": 0, + "data": { + "total_users": total_users, + "quota_users": total_quota_users, + "users_without_quota": users_without_quota, + "total_quota_bytes": int(total_quota_bytes), + "total_used_bytes": int(total_used_bytes), + "over_quota_count": over_quota_count, + "by_level": by_level, + "default_quotas": {str(k): v for k, v in DEFAULT_QUOTAS.items()}, + "tier_labels": TIER_LABELS, + }, + "message": "ok" + } + + +@router.get("/quota/users", summary="User Storage Quota List") +async def list_user_quotas( + keyword: str = Query(None), + membership_level: int = Query(None), + over_quota: bool = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + results = db.query( + User.id, User.username, User.email, User.membership_level, User.real_name, + UserStorageQuota.id.label("quota_id"), + UserStorageQuota.quota_bytes, UserStorageQuota.used_bytes, + UserStorageQuota.quota_type, UserStorageQuota.updated_at, + ).join(UserStorageQuota, User.id == UserStorageQuota.user_id, isouter=True + ).filter(User.status == "active") + if keyword: + results = results.filter( + or_(User.username.ilike(f"%{keyword}%"), User.email.ilike(f"%{keyword}%")) + ) + if membership_level is not None: + results = results.filter(User.membership_level == membership_level) + total = results.count() + items = results.order_by(desc(UserStorageQuota.used_bytes)).offset( + (page - 1) * page_size).limit(page_size).all() + user_list = [] + for row in items: + uid, username, email, level, real_name, qid, qbytes, ubytes, qtype, updated = row + quota_b = int(qbytes or DEFAULT_QUOTAS.get(level or 0, DEFAULT_QUOTAS[0])) + used_b = int(ubytes or 0) + usage_pct = round(used_b / quota_b * 100, 1) if quota_b > 0 else 0 + user_list.append({ + "user_id": uid, "username": username, "email": email or "", + "real_name": real_name or "", "membership_level": level or 0, + "tier_label": TIER_LABELS.get(level or 0, "\u514d\u8d39\u7248"), + "quota_id": qid, "quota_bytes": quota_b, "used_bytes": used_b, + "usage_percent": usage_pct, "is_over_quota": usage_pct >= 100, + "quota_type": qtype or "membership", + "updated_at": updated.isoformat() if updated else None, + }) + if over_quota: + user_list = [u for u in user_list if u["is_over_quota"]] + return { + "code": 0, + "data": { + "items": user_list, + "total": len(user_list) if over_quota else total, + "page": page, "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/quota/users/{user_id}", summary="Get User Quota Detail") +async def get_user_quota( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User, KnowledgeDocument, KnowledgeBase + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + actual_used = recalculate_storage_usage(user_id, db) + file_count = db.query(func.count(KnowledgeDocument.id)).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter(KnowledgeBase.user_id == user_id).scalar() or 0 + file_type_stats = {} + type_counts = db.query( + KnowledgeDocument.file_type, func.count(KnowledgeDocument.id), + func.coalesce(func.sum(KnowledgeDocument.file_size), 0), + ).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter(KnowledgeBase.user_id == user_id + ).group_by(KnowledgeDocument.file_type).all() + for ftype, cnt, fsize in type_counts: + file_type_stats[ftype] = {"count": cnt, "total_bytes": int(fsize)} + quota_data = None + if quota: + quota_data = { + "id": quota.id, "quota_bytes": int(quota.quota_bytes), + "used_bytes": actual_used, "original_used": int(quota.used_bytes), + "quota_type": quota.quota_type, "membership_level": quota.membership_level, + "plan_id": quota.plan_id, + "created_at": quota.created_at.isoformat() if quota.created_at else None, + "updated_at": quota.updated_at.isoformat() if quota.updated_at else None, + } + if actual_used != int(quota.used_bytes): + quota.used_bytes = actual_used + db.commit() + return { + "code": 0, + "data": { + "user": { + "id": user.id, "username": user.username, + "email": user.email or "", "real_name": user.real_name or "", + "membership_level": user.membership_level or 0, + "tier_label": TIER_LABELS.get(user.membership_level or 0, "\u514d\u8d39\u7248"), + }, + "quota": quota_data, "file_count": file_count, + "file_type_stats": file_type_stats, + }, + "message": "ok" + } + + +@router.put("/quota/users/{user_id}", summary="Adjust User Quota") +async def adjust_user_quota( + user_id: str, + req: QuotaAdjustRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + if not quota: + default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0]) + quota = UserStorageQuota( + id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes, + used_bytes=0, quota_type="membership", + membership_level=user.membership_level or 0, + ) + db.add(quota) + db.flush() + if req.quota_bytes is not None: + quota.quota_bytes = req.quota_bytes + if req.quota_type is not None: + quota.quota_type = req.quota_type + quota.used_bytes = recalculate_storage_usage(user_id, db) + db.commit() + db.refresh(quota) + LogService.log_audit( + admin_id=admin_user.id, admin_name=admin_user.username, + action="adjust_storage_quota", target_type="user_storage_quota", + target_id=quota.id, + detail=f"Adjusted quota for {user.username} to {quota.quota_bytes} bytes" + ) + usage_pct = round(quota.used_bytes / quota.quota_bytes * 100, 1) if quota.quota_bytes > 0 else 0 + return { + "code": 0, + "data": { + "id": quota.id, "user_id": quota.user_id, + "quota_bytes": int(quota.quota_bytes), + "used_bytes": int(quota.used_bytes), + "quota_type": quota.quota_type, "usage_percent": usage_pct, + }, + "message": "Quota adjusted successfully" + } + + +@router.post("/quota/recalculate/{user_id}", summary="Recalculate User Storage") +async def recalculate_user_quota( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + actual_used = recalculate_storage_usage(user_id, db) + if quota: + quota.used_bytes = actual_used + db.commit() + return { + "code": 0, + "data": { + "user_id": user_id, "used_bytes": actual_used, + "quota_bytes": int(quota.quota_bytes), + }, + "message": f"Recalculated: {actual_used} bytes" + } + else: + return { + "code": 0, + "data": {"user_id": user_id, "used_bytes": actual_used}, + "message": "No quota record for this user" + } + + +@router.post("/quota/init-all", summary="Init All User Quotas") +async def init_all_user_quotas( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + users = db.query(User).filter(User.status == "active").all() + created, skipped = 0, 0 + for user in users: + existing = db.query(UserStorageQuota).filter( + UserStorageQuota.user_id == user.id).first() + if existing: + skipped += 1 + continue + default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0]) + used = recalculate_storage_usage(user.id, db) + quota = UserStorageQuota( + id=gen_uuid(), user_id=user.id, quota_bytes=default_bytes, + used_bytes=used, quota_type="membership", + membership_level=user.membership_level or 0, + plan_id=user.membership_plan_id, + ) + db.add(quota) + created += 1 + db.commit() + return { + "code": 0, + "data": {"created": created, "skipped": skipped, "total": len(users)}, + "message": f"Init complete: {created} new, {skipped} existing, {len(users)} total" + } + + + async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str, bucket: str, region: str = "") -> Dict[str, Any]: """测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点""" diff --git a/backend/python/app/models.py b/backend/python/app/models.py index da31e65..5977785 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -486,6 +486,7 @@ class AIModel(Base): id = Column(String(36), primary_key=True, default=gen_uuid) provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False) name = Column(String(100), nullable=False) + display_name = Column(String(200), default=None, comment="display name") api_key = Column(String(255), default=None) endpoint = Column(String(500), default=None) capabilities = Column(JSON, default=None) @@ -1004,6 +1005,22 @@ class OSSStorageConfig(Base): created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class UserStorageQuota(Base): + __tablename__ = "user_storage_quotas" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), nullable=False, unique=True) + quota_bytes = Column(BigInteger, nullable=False, default=1073741824) + used_bytes = Column(BigInteger, nullable=False, default=0) + quota_type = Column(String(20), nullable=False, default="membership") + membership_level = Column(Integer, default=0) + plan_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + class AutomatedTask(Base): __tablename__ = "automated_tasks" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 61b049d..2e1bf3c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -513,6 +513,19 @@ export const adminAPI = { toggleAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/toggle`), setDefaultAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/set-default`), testAdminStorageConfig: (data: any) => post('/admin/storage/test', data), + // Storage Quota APIs (Phase C5) + getStorageQuotaOverview: () => get('/admin/storage/quota/overview'), + getStorageQuotaUsers: (params?: any) => { + if (params) { + const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&'); + return get(`/admin/storage/quota/users${query ? '?' + query : ''}`); + } + return get('/admin/storage/quota/users'); + }, + getStorageQuotaUser: (userId: string) => get(`/admin/storage/quota/users/${userId}`), + adjustStorageQuota: (userId: string, data: any) => put(`/admin/storage/quota/users/${userId}`, data), + recalcStorageQuota: (userId: string) => post(`/admin/storage/quota/recalculate/${userId}`, {}), + initAllStorageQuotas: () => post('/admin/storage/quota/init-all', {}), // 自动化任务调度 (Phase 3.2) getAdminAutomationStats: () => get('/admin/automation/stats/overview'), diff --git a/frontend/src/pages/admin/Storage.tsx b/frontend/src/pages/admin/Storage.tsx index 8ba3ff4..2ea9355 100644 --- a/frontend/src/pages/admin/Storage.tsx +++ b/frontend/src/pages/admin/Storage.tsx @@ -1,6 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { adminAPI } from '@/api'; +// ============================================================ +// Types +// ============================================================ + interface OSSConfig { id: string; name: string; @@ -26,53 +30,125 @@ interface StorageStats { by_status: Record; } +interface QuotaOverview { + total_users: number; + quota_users: number; + users_without_quota: number; + total_quota_bytes: number; + total_used_bytes: number; + over_quota_count: number; + by_level: Record; + default_quotas: Record; + tier_labels: Record; +} + +interface QuotaUser { + user_id: string; + username: string; + email: string; + real_name: string; + membership_level: number; + tier_label: string; + quota_id: string | null; + quota_bytes: number; + used_bytes: number; + usage_percent: number; + is_over_quota: boolean; + quota_type: string; + updated_at: string | null; +} + +interface QuotaDetail { + user: { + id: string; + username: string; + email: string; + real_name: string; + membership_level: number; + tier_label: string; + }; + quota: { + id: string; + quota_bytes: number; + used_bytes: number; + original_used: number; + quota_type: string; + membership_level: number; + plan_id: string | null; + created_at: string; + updated_at: string; + } | null; + file_count: number; + file_type_stats: Record; +} + +// ============================================================ +// Constants +// ============================================================ + const PROVIDER_CONFIG: Record = { - minio: { label: 'MinIO', color: '#C9362C', bg: '#FEF0EF' }, - aliyun_oss: { label: '阿里云OSS', color: '#FF6A00', bg: '#FFF3E8' }, - aws_s3: { label: 'AWS S3', color: '#FF9900', bg: '#FFF8E1' }, + minio: { label: 'MinIO', color: '#C9362C', bg: 'rgba(201,54,44,0.15)' }, + aliyun_oss: { label: 'OSS', color: '#FF6A00', bg: 'rgba(255,106,0,0.15)' }, + aws_s3: { label: 'AWS S3', color: '#FF9900', bg: 'rgba(255,153,0,0.15)' }, }; const STATUS_CONFIG: Record = { - connected: { label: '已连接', color: '#10B981', bg: '#D1FAE5' }, - disconnected: { label: '未连接', color: '#6B7280', bg: '#F3F4F6' }, - error: { label: '连接异常', color: '#EF4444', bg: '#FEE2E2' }, + connected: { label: 'Connected', color: '#10B981', bg: 'rgba(16,185,129,0.15)' }, + disconnected: { label: 'Offline', color: '#6B7280', bg: 'rgba(107,114,128,0.15)' }, + error: { label: 'Error', color: '#EF4444', bg: 'rgba(239,68,68,0.15)' }, }; const PAGE_SIZE = 15; +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// ============================================================ +// Main Component +// ============================================================ + const Storage: React.FC = () => { - // Data state + const [activeTab, setActiveTab] = useState<'oss' | 'quota'>('oss'); + + // ============================================================ + // OSS Tab State + // ============================================================ const [configs, setConfigs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [stats, setStats] = useState(null); - - // Filter state const [filterProvider, setFilterProvider] = useState(''); const [keyword, setKeyword] = useState(''); - - // Modal state const [modalVisible, setModalVisible] = useState(false); const [editingConfig, setEditingConfig] = useState(null); const [formData, setFormData] = useState({ - name: '', - provider: 'minio', - endpoint: '', - access_key: '', - secret_key: '', - bucket: '', - region: '', - base_path: '', - is_default: false, - is_enabled: true, - config_str: '', + name: '', provider: 'minio', endpoint: '', access_key: '', secret_key: '', + bucket: '', region: '', base_path: '', is_default: false, is_enabled: true, config_str: '', }); const [submitting, setSubmitting] = useState(false); - - // Test connection state const [testingId, setTestingId] = useState(null); - const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency_ms: number } | null>(null); + + // ============================================================ + // Quota Tab State + // ============================================================ + const [quotaOverview, setQuotaOverview] = useState(null); + const [quotaUsers, setQuotaUsers] = useState([]); + const [quotaTotal, setQuotaTotal] = useState(0); + const [quotaPage, setQuotaPage] = useState(1); + const [quotaLoading, setQuotaLoading] = useState(false); + const [quotaKeyword, setQuotaKeyword] = useState(''); + const [quotaLevelFilter, setQuotaLevelFilter] = useState(''); + const [quotaDetail, setQuotaDetail] = useState(null); + const [adjustModal, setAdjustModal] = useState(null); + const [adjustValue, setAdjustValue] = useState(''); + const [adjustUnit, setAdjustUnit] = useState<'GB' | 'MB'>('GB'); + const [adjusting, setAdjusting] = useState(false); // Toast const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); @@ -81,17 +157,16 @@ const Storage: React.FC = () => { setTimeout(() => setToast(null), 3000); }; - // Load stats + // ============================================================ + // OSS Tab Functions + // ============================================================ const loadStats = async () => { try { const result = await adminAPI.getAdminStorageStats() as any; setStats(result.data || null); - } catch (e) { - // ignore - } + } catch (e) { /* ignore */ } }; - // Load configs const loadConfigs = async () => { setLoading(true); try { @@ -103,592 +178,695 @@ const Storage: React.FC = () => { setConfigs(data.items || []); setTotal(data.total || 0); } catch (e: any) { - showToast('加载失败: ' + (e.message || e), 'error'); + showToast('Load failed: ' + (e.message || e), 'error'); } finally { setLoading(false); } }; - useEffect(() => { - loadStats(); - }, []); + useEffect(() => { loadStats(); }, []); + useEffect(() => { loadConfigs(); }, [page, filterProvider, keyword]); - useEffect(() => { - loadConfigs(); - }, [page, filterProvider, keyword]); - - // Open create modal const openCreateModal = () => { setEditingConfig(null); - setFormData({ - name: '', - provider: 'minio', - endpoint: '', - access_key: '', - secret_key: '', - bucket: '', - region: '', - base_path: '', - is_default: false, - is_enabled: true, - config_str: '', - }); + setFormData({ name: '', provider: 'minio', endpoint: '', access_key: '', secret_key: '', bucket: '', region: '', base_path: '', is_default: false, is_enabled: true, config_str: '' }); setModalVisible(true); }; - // Open edit modal const openEditModal = async (config: OSSConfig) => { try { const result = await adminAPI.getAdminStorageConfig(config.id) as any; const detail = result.data; setEditingConfig(config); setFormData({ - name: detail.name || '', - provider: detail.provider || 'minio', - endpoint: detail.endpoint || '', - access_key: '', // Not returned for security - secret_key: '', // Not returned for security - bucket: detail.bucket || '', - region: detail.region || '', - base_path: detail.base_path || '', - is_default: detail.is_default || false, - is_enabled: detail.is_enabled !== false, - config_str: detail.config ? JSON.stringify(detail.config, null, 2) : '', + name: detail.name || '', provider: detail.provider || 'minio', endpoint: detail.endpoint || '', + access_key: '', secret_key: '', bucket: detail.bucket || '', region: detail.region || '', + base_path: detail.base_path || '', is_default: detail.is_default || false, + is_enabled: detail.is_enabled !== false, config_str: detail.config ? JSON.stringify(detail.config, null, 2) : '', }); setModalVisible(true); - } catch (e: any) { - showToast('获取配置详情失败', 'error'); - } + } catch (e: any) { showToast('Failed to get config detail', 'error'); } }; - // Submit form const handleSubmit = async () => { - if (!formData.name.trim()) { showToast('请输入配置名称', 'error'); return; } - if (!formData.endpoint.trim()) { showToast('请输入端点地址', 'error'); return; } - if (!formData.bucket.trim()) { showToast('请输入Bucket名称', 'error'); return; } - - // For create, require credentials + if (!formData.name.trim()) { showToast('Name required', 'error'); return; } + if (!formData.endpoint.trim()) { showToast('Endpoint required', 'error'); return; } + if (!formData.bucket.trim()) { showToast('Bucket required', 'error'); return; } if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) { - showToast('请输入Access Key和Secret Key', 'error'); - return; + showToast('Access Key and Secret Key required', 'error'); return; } - setSubmitting(true); try { let configObj: any = undefined; if (formData.config_str.trim()) { - try { - configObj = JSON.parse(formData.config_str); - } catch { - showToast('扩展配置JSON格式不正确', 'error'); - setSubmitting(false); - return; - } + try { configObj = JSON.parse(formData.config_str); } + catch { showToast('Invalid JSON config', 'error'); setSubmitting(false); return; } } - const data: any = { - name: formData.name.trim(), - provider: formData.provider, - endpoint: formData.endpoint.trim(), - bucket: formData.bucket.trim(), - region: formData.region.trim(), - base_path: formData.base_path.trim(), - is_default: formData.is_default, - is_enabled: formData.is_enabled, - config: configObj, + name: formData.name.trim(), provider: formData.provider, endpoint: formData.endpoint.trim(), + bucket: formData.bucket.trim(), region: formData.region.trim(), base_path: formData.base_path.trim(), + is_default: formData.is_default, is_enabled: formData.is_enabled, config: configObj, }; - - // Include credentials if provided (for create or update) if (formData.access_key.trim()) data.access_key = formData.access_key.trim(); if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim(); - - if (editingConfig) { - await adminAPI.updateAdminStorageConfig(editingConfig.id, data); - showToast('更新成功'); - } else { - await adminAPI.createAdminStorageConfig(data); - showToast('创建成功'); - } - - setModalVisible(false); - loadConfigs(); - loadStats(); - } catch (e: any) { - showToast('操作失败: ' + (e.message || e), 'error'); - } finally { - setSubmitting(false); - } + if (editingConfig) { await adminAPI.updateAdminStorageConfig(editingConfig.id, data); showToast('Updated'); } + else { await adminAPI.createAdminStorageConfig(data); showToast('Created'); } + setModalVisible(false); loadConfigs(); loadStats(); + } catch (e: any) { showToast('Failed: ' + (e.message || e), 'error'); } + finally { setSubmitting(false); } }; - // Delete config const handleDelete = async (config: OSSConfig) => { - if (!window.confirm(`确定要删除配置 "${config.name}" 吗?此操作不可恢复。`)) return; - try { - await adminAPI.deleteAdminStorageConfig(config.id); - showToast('删除成功'); - loadConfigs(); - loadStats(); - } catch (e: any) { - showToast('删除失败: ' + (e.message || e), 'error'); - } + if (!window.confirm(`Delete "${config.name}"?`)) return; + try { await adminAPI.deleteAdminStorageConfig(config.id); showToast('Deleted'); loadConfigs(); loadStats(); } + catch (e: any) { showToast('Delete failed', 'error'); } }; - // Toggle enabled const handleToggle = async (config: OSSConfig) => { - try { - await adminAPI.toggleAdminStorageConfig(config.id); - showToast(config.is_enabled ? '已禁用' : '已启用'); - loadConfigs(); - loadStats(); - } catch (e: any) { - showToast('操作失败: ' + (e.message || e), 'error'); - } + try { await adminAPI.toggleAdminStorageConfig(config.id); showToast(config.is_enabled ? 'Disabled' : 'Enabled'); loadConfigs(); loadStats(); } + catch (e: any) { showToast('Failed', 'error'); } }; - // Set default const handleSetDefault = async (config: OSSConfig) => { if (config.is_default) return; - try { - await adminAPI.setDefaultAdminStorageConfig(config.id); - showToast(`已将 "${config.name}" 设为默认存储`); - loadConfigs(); - loadStats(); - } catch (e: any) { - showToast('操作失败: ' + (e.message || e), 'error'); - } + try { await adminAPI.setDefaultAdminStorageConfig(config.id); showToast(`Set "${config.name}" as default`); loadConfigs(); loadStats(); } + catch (e: any) { showToast('Failed', 'error'); } }; - // Test connection const handleTestConnection = async (config: OSSConfig) => { setTestingId(config.id); - setTestResult(null); try { const result = await adminAPI.testAdminStorageConfig({ config_id: config.id }) as any; const data = result.data; - setTestResult(data); - showToast(data.success ? '连接测试成功' : '连接测试失败', data.success ? 'success' : 'error'); - loadConfigs(); - loadStats(); - } catch (e: any) { - showToast('测试失败: ' + (e.message || e), 'error'); - } finally { - setTestingId(null); + showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error'); + loadConfigs(); loadStats(); + } catch (e: any) { showToast('Test failed', 'error'); } + finally { setTestingId(null); } + }; + + // ============================================================ + // Quota Tab Functions + // ============================================================ + const loadQuotaOverview = async () => { + try { + const result = await adminAPI.getStorageQuotaOverview() as any; + setQuotaOverview(result.data || null); + } catch (e) { /* ignore */ } + }; + + const loadQuotaUsers = async () => { + setQuotaLoading(true); + try { + const params: any = { page: quotaPage, page_size: PAGE_SIZE }; + if (quotaKeyword) params.keyword = quotaKeyword; + if (quotaLevelFilter !== '') params.membership_level = quotaLevelFilter; + const result = await adminAPI.getStorageQuotaUsers(params) as any; + const data = result.data || result; + setQuotaUsers(data.items || []); + setQuotaTotal(data.total || 0); + } catch (e: any) { showToast('Load failed', 'error'); } + finally { setQuotaLoading(false); } + }; + + useEffect(() => { if (activeTab === 'quota') { loadQuotaOverview(); } }, [activeTab]); + useEffect(() => { if (activeTab === 'quota') { loadQuotaUsers(); } }, [activeTab, quotaPage, quotaKeyword, quotaLevelFilter]); + + const handleInitAllQuotas = async () => { + if (!window.confirm('Initialize storage quotas for all users without existing quota records?')) return; + try { + const result = await adminAPI.initAllStorageQuotas() as any; + showToast(result.message || 'Init complete'); + loadQuotaOverview(); loadQuotaUsers(); + } catch (e: any) { showToast('Init failed', 'error'); } + }; + + const handleRecalcUser = async (userId: string) => { + try { + const result = await adminAPI.recalcStorageQuota(userId) as any; + showToast(result.message || 'Recalculated'); + loadQuotaUsers(); + } catch (e: any) { showToast('Recalc failed', 'error'); } + }; + + const handleViewDetail = async (userId: string) => { + try { + const result = await adminAPI.getStorageQuotaUser(userId) as any; + setQuotaDetail(result.data || null); + } catch (e: any) { showToast('Failed to load detail', 'error'); } + }; + + const openAdjustModal = (user: QuotaUser) => { + setAdjustModal(user); + const gb = user.quota_bytes / (1024 * 1024 * 1024); + if (gb >= 1) { + setAdjustValue(String(Math.round(gb * 100) / 100)); + setAdjustUnit('GB'); + } else { + setAdjustValue(String(Math.round(user.quota_bytes / (1024 * 1024)))); + setAdjustUnit('MB'); } }; + const handleAdjustSubmit = async () => { + if (!adjustModal) return; + const numVal = parseFloat(adjustValue); + if (isNaN(numVal) || numVal < 0) { showToast('Invalid value', 'error'); return; } + const bytes = adjustUnit === 'GB' ? Math.round(numVal * 1024 * 1024 * 1024) : Math.round(numVal * 1024 * 1024); + setAdjusting(true); + try { + await adminAPI.adjustStorageQuota(adjustModal.user_id, { quota_bytes: bytes, quota_type: 'custom' }); + showToast('Quota adjusted'); + setAdjustModal(null); + loadQuotaUsers(); loadQuotaOverview(); + } catch (e: any) { showToast('Adjust failed', 'error'); } + finally { setAdjusting(false); } + }; + + // ============================================================ + // Render + // ============================================================ return (
-

OSS存储配置管理

- +

Storage Management

+ {activeTab === 'oss' && ( + + )} + {activeTab === 'quota' && ( + + )}
- {/* Stats Cards */} - {stats && ( -
-
-
{stats.total}
-
总配置数
-
-
-
{stats.enabled_count}
-
已启用
-
-
-
{stats.default_count}
-
默认存储
-
-
-
- {Object.entries(stats.by_status || {}).map(([s, c]) => ( - - {(STATUS_CONFIG[s] || {}).label || s}: {c} - - ))} -
-
连接状态
-
-
- )} - - {/* Filter Bar */} -
- - { setKeyword(e.target.value); setPage(1); }} - style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} - /> + OSS Configuration + +
- {/* Table */} -
- - - - - - - - - - - - - - {loading ? ( - - ) : configs.length === 0 ? ( - - ) : ( - configs.map(cfg => ( - - - - - - - - + ))} + +
Status
+ + + )} + +
+ + { setKeyword(e.target.value); setPage(1); }} + style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#111627', color: '#fff', fontSize: '14px', flex: 1, maxWidth: '400px' }} /> +
+ +
+
配置名称提供商端点地址Bucket状态默认操作
加载中...
暂无存储配置,点击"添加存储配置"开始
{cfg.name} - + {stats && ( +
+
+
{stats.total}
+
Total Configs
+
+
+
{stats.enabled_count}
+
Enabled
+
+
+
{stats.default_count}
+
Default
+
+
+
+ {Object.entries(stats.by_status || {}).map(([s, c]) => ( + - {(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider} + {(STATUS_CONFIG[s] || {}).label || s}: {c} -
- {cfg.endpoint} - {cfg.bucket} - - {(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label} - - - {cfg.is_default ? ( - 默认 - ) : ( - - )} - -
- - - - -
-
+ + + + + + + + + - )) - )} - -
NameProviderEndpointBucketStatusDefaultActions
-
+ + + {loading ? ( + Loading... + ) : configs.length === 0 ? ( + No storage configs + ) : ( + configs.map(cfg => ( + + {cfg.name} + + + {(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider} + + + {cfg.endpoint} + {cfg.bucket} + + + {(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label} + + + + {cfg.is_default ? Default : + } + + +
+ + + + +
+ + + )) + )} + + +
- {/* Pagination */} - {total > PAGE_SIZE && ( -
- - - 第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条) - - -
+ {total > PAGE_SIZE && ( +
+ + Page {page} / {Math.ceil(total / PAGE_SIZE)} ({total}) + +
+ )} + )} - {/* Modal */} + {/* ============================================================ */} + {/* Quota Tab */} + {/* ============================================================ */} + {activeTab === 'quota' && ( + <> + {/* Overview Stats */} + {quotaOverview && ( + <> +
+
+
{quotaOverview.total_users}
+
Total Users
+
+
+
{quotaOverview.quota_users}
+
With Quota
+
+
+
{formatBytes(quotaOverview.total_quota_bytes)}
+
Total Quota
+
+
+
{formatBytes(quotaOverview.total_used_bytes)}
+
Total Used
+
+
+
0 ? '#EF4444' : '#10B981' }}>{quotaOverview.over_quota_count}
+
Over Quota
+
+
+ + {/* Tier Defaults */} +
+ {Object.entries(quotaOverview.default_quotas || {}).map(([level, bytes]) => ( +
+
+ + {(quotaOverview.tier_labels || {})[level] || `Level ${level}`} + + + Default + +
+
{formatBytes(bytes)}
+
+ {(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} users | Used: ${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : 'No users'} +
+
+ ))} +
+ + )} + + {/* Filter Bar */} +
+ + { setQuotaKeyword(e.target.value); setQuotaPage(1); }} + style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#111627', color: '#fff', fontSize: '14px', flex: 1, maxWidth: '400px' }} /> +
+ + {/* Quota Users Table */} +
+ + + + + + + + + + + + + {quotaLoading ? ( + + ) : quotaUsers.length === 0 ? ( + + ) : ( + quotaUsers.map(u => ( + + + + + + + + + )) + )} + +
UserTierUsageUsed / QuotaTypeActions
Loading...
No users found. Click "Init All Quotas" to initialize.
+
{u.username}
+
{u.email || '-'}
+
+ {u.tier_label} + +
+
+
80 ? '#F59E0B' : '#10B981', + transition: 'width 0.3s ease', + }} /> +
+ 80 ? '#F59E0B' : '#10B981' }}> + {u.usage_percent}% + +
+
+ {formatBytes(u.used_bytes)} / {formatBytes(u.quota_bytes)} + + {u.quota_type === 'custom' ? 'Custom' : 'Plan'} + +
+ + + +
+
+
+ + {quotaTotal > PAGE_SIZE && ( +
+ + Page {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} ({quotaTotal}) + +
+ )} + + )} + + {/* ============================================================ */} + {/* OSS Config Modal */} + {/* ============================================================ */} {modalVisible && (
-

- {editingConfig ? '编辑存储配置' : '添加存储配置'} +

+ {editingConfig ? 'Edit Storage Config' : 'Add Storage Config'}

-
- {/* Name */}
- - setFormData({ ...formData, name: e.target.value })} - placeholder="如: 生产环境MinIO" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Production MinIO" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - {/* Provider */}
- - setFormData({ ...formData, provider: e.target.value })} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }}> - +
- - {/* Endpoint */}
- - setFormData({ ...formData, endpoint: e.target.value })} - placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : formData.provider === 'aliyun_oss' ? 'https://oss-cn-hangzhou.aliyuncs.com' : 'https://s3.amazonaws.com'} - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, endpoint: e.target.value })} + 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(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - {/* Access Key & Secret Key */}
- - setFormData({ ...formData, access_key: e.target.value })} - placeholder={editingConfig ? '留空则不修改' : '输入Access Key'} - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, access_key: e.target.value })} + placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Access Key'} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - setFormData({ ...formData, secret_key: e.target.value })} - placeholder={editingConfig ? '留空则不修改' : '输入Secret Key'} - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, secret_key: e.target.value })} + placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Secret Key'} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - {/* Bucket & Region */}
- - setFormData({ ...formData, bucket: e.target.value })} + + setFormData({ ...formData, bucket: e.target.value })} placeholder="my-bucket" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - setFormData({ ...formData, region: e.target.value })} - placeholder="如: us-east-1" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, region: e.target.value })} + placeholder="us-east-1" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - {/* Base Path */}
- - setFormData({ ...formData, base_path: e.target.value })} - placeholder="如: aipaaS/uploads/" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} - /> + + setFormData({ ...formData, base_path: e.target.value })} + placeholder="aipaaS/uploads/" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.15)', background: '#0A0E1A', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }} />
- - {/* Checkboxes */}
-
- - {/* Advanced Config */}
- -