diff --git a/backend/python/app/api/admin/asset_admin.py b/backend/python/app/api/admin/asset_admin.py new file mode 100644 index 0000000..6743f92 --- /dev/null +++ b/backend/python/app/api/admin/asset_admin.py @@ -0,0 +1,357 @@ +""" +资产中心管理 API(管理员) +Phase 3.8: Asset Center Enhancement - Admin asset management, analytics, activity log +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, text +from typing import Optional +import json +from datetime import datetime, timedelta + +from ...database import get_db, SessionLocal +from ...models import Asset, AssetCategory, UserStorageQuota, User +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() + +CATEGORY_LABELS = { + "file": "文件", "novel": "小说", "image": "图片", + "video": "视频", "audio": "音频", "custom": "自定义", "doc": "文档", +} + + +def _execute_raw(db, sql, params=None): + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows] + except: + return [] + + +def _asset_to_dict(a): + return { + "id": a.id, + "user_id": a.user_id, + "name": a.name, + "category": a.category, + "sub_category": a.sub_category, + "file_type": a.file_type, + "size": a.size or 0, + "description": a.description, + "cover_url": a.cover_url, + "url": a.url, + "status": a.status, + "created_at": a.created_at.strftime('%Y-%m-%d %H:%M:%S') if a.created_at else None, + "updated_at": a.updated_at.strftime('%Y-%m-%d %H:%M:%S') if a.updated_at else None, + } + + +# ============================================================ +# Stats / Overview +# ============================================================ + +@router.get("/stats", summary="资产中心总览") +async def get_asset_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取资产中心总览数据""" + # Total assets + total_assets = _execute_raw(db, "SELECT COUNT(*) as cnt FROM assets WHERE status = 'active'") + total = total_assets[0]["cnt"] if total_assets else 0 + + # Total size + total_size = _execute_raw(db, "SELECT COALESCE(SUM(size), 0) as sz FROM assets WHERE status = 'active'") + size = total_size[0]["sz"] if total_size else 0 + + # By category + cat_stats = _execute_raw(db, """ + SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE status = 'active' + GROUP BY category + """) + + # By file type + type_stats = _execute_raw(db, """ + SELECT COALESCE(file_type, 'unknown') as ft, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE status = 'active' + GROUP BY file_type + ORDER BY cnt DESC + LIMIT 10 + """) + + # Top users by storage + top_users = _execute_raw(db, """ + SELECT a.user_id, u.username, u.avatar, + COUNT(*) as asset_count, COALESCE(SUM(a.size), 0) as total_size + FROM assets a + LEFT JOIN users u ON a.user_id = u.id + WHERE a.status = 'active' + GROUP BY a.user_id, u.username, u.avatar + ORDER BY total_size DESC + LIMIT 10 + """) + + # Storage quota summary + quota_stats = _execute_raw(db, """ + SELECT + COUNT(*) as total_users, + COALESCE(SUM(quota_bytes), 0) as total_quota, + COALESCE(SUM(used_bytes), 0) as total_used + FROM user_storage_quotas + """) + + # Recent 7 days trend + trend = [] + for i in range(6, -1, -1): + date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') + new_uploads = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM assets + WHERE DATE(created_at) = :d AND status = 'active' + """, {"d": date}) + trend.append({ + "date": date, + "new_uploads": new_uploads[0]["cnt"] if new_uploads else 0, + }) + + # Categories list + categories = _execute_raw(db, """ + SELECT type, COUNT(*) as cnt FROM asset_categories GROUP BY type + """) + + return { + "code": 0, + "data": { + "total_assets": total, + "total_size": size, + "total_size_formatted": _format_size(size), + "by_category": [{ + "category": r["category"], + "label": CATEGORY_LABELS.get(r["category"], r["category"]), + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in cat_stats], + "by_file_type": [{ + "file_type": r["ft"], + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in type_stats], + "top_users": [{ + "user_id": r["user_id"], + "username": r["username"] or "Unknown", + "avatar": r["avatar"], + "asset_count": r["asset_count"], + "total_size": r["total_size"], + "total_size_formatted": _format_size(r["total_size"]), + } for r in top_users], + "quota_summary": { + "total_users": quota_stats[0]["total_users"] if quota_stats else 0, + "total_quota": quota_stats[0]["total_quota"] if quota_stats else 0, + "total_used": quota_stats[0]["total_used"] if quota_stats else 0, + "total_quota_formatted": _format_size(quota_stats[0]["total_quota"]) if quota_stats else "0", + "total_used_formatted": _format_size(quota_stats[0]["total_used"]) if quota_stats else "0", + "usage_percent": round((quota_stats[0]["total_used"] / quota_stats[0]["total_quota"] * 100), 2) if quota_stats and quota_stats[0]["total_quota"] > 0 else 0, + }, + "trend_7d": trend, + "categories": [{"type": r["type"], "count": r["cnt"]} for r in categories], + } + } + + +def _format_size(bytes_val): + if not bytes_val or bytes_val == 0: + return "0 B" + if bytes_val < 1024: + return f"{bytes_val} B" + if bytes_val < 1024 * 1024: + return f"{bytes_val / 1024:.1f} KB" + if bytes_val < 1024 * 1024 * 1024: + return f"{bytes_val / (1024 * 1024):.1f} MB" + return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB" + + +# ============================================================ +# Asset list & management +# ============================================================ + +@router.get("/", summary="List all assets") +async def list_all_assets( + user_id: str = Query(None), + category: str = Query(None), + file_type: str = Query(None), + keyword: str = Query(None), + status: str = Query("active"), + 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), +): + """List all user assets""" + where_clauses = [] + params = {} + if status: + where_clauses.append("a.status = :status") + params["status"] = status + if user_id: + where_clauses.append("a.user_id = :uid") + params["uid"] = user_id + if category: + where_clauses.append("a.category = :cat") + params["cat"] = category + if file_type: + where_clauses.append("a.file_type = :ft") + params["ft"] = file_type + if keyword: + where_clauses.append("(a.name LIKE :kw OR a.description LIKE :kw)") + params["kw"] = "%" + keyword + "%" + + where_sql = " AND ".join(where_clauses) + where_clause = "WHERE " + where_sql if where_sql else "" + + count_sql = "SELECT COUNT(*) as cnt FROM assets a " + where_clause + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + offset = (page - 1) * page_size + list_sql = "SELECT a.id, a.user_id, a.name, a.category, a.sub_category, a.file_type, a.size, a.description, a.cover_url, a.url, a.status, a.created_at, a.updated_at, u.username, u.avatar FROM assets a LEFT JOIN users u ON a.user_id = u.id " + where_clause + " ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset" + list_params = dict(params) + list_params["limit"] = page_size + list_params["offset"] = offset + rows = _execute_raw(db, list_sql, list_params) + + items = [] + for r in rows: + items.append({ + "id": r["id"], "user_id": r["user_id"], "name": r["name"], + "category": r["category"], "sub_category": r["sub_category"], + "file_type": r["file_type"], "size": r["size"] or 0, + "size_formatted": _format_size(r["size"] or 0), + "description": r["description"], "cover_url": r["cover_url"], + "url": r["url"], "status": r["status"], + "username": r["username"] or "Unknown", "avatar": r["avatar"], + "created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S") if r["created_at"] else None, + "updated_at": r["updated_at"].strftime("%Y-%m-%d %H:%M:%S") if r["updated_at"] else None, + }) + + return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}} + + + +@router.delete("/{asset_id}", summary="管理员删除资产") +async def admin_delete_asset( + asset_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员删除用户资产""" + asset = db.query(Asset).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(status_code=404, detail="资产不存在") + + asset_name = asset.name + asset.status = "deleted" + asset.updated_at = datetime.now() + db.commit() + + LogService.log_action(db, "admin", admin_user["id"], "delete_asset", f"Deleted asset: {asset_name}") + + return {"code": 0, "message": "资产已删除"} + + +@router.get("/activity", summary="最近资产活动") +async def get_asset_activity( + limit: int = Query(30, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取最近的资产活动""" + activities = _execute_raw(db, """ + SELECT a.id, a.name, a.category, a.file_type, a.size, a.status, + a.user_id, u.username, u.avatar, + a.created_at, a.updated_at + FROM assets a + LEFT JOIN users u ON a.user_id = u.id + ORDER BY a.updated_at DESC + LIMIT :lim + """, {"lim": limit}) + + return { + "code": 0, + "data": [{ + "id": r["id"], + "name": r["name"], + "category": r["category"], + "category_label": CATEGORY_LABELS.get(r["category"], r["category"]), + "file_type": r["file_type"], + "size": r["size"] or 0, + "size_formatted": _format_size(r["size"] or 0), + "status": r["status"], + "user_id": r["user_id"], + "username": r["username"] or "Unknown", + "avatar": r["avatar"], + "created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None, + "updated_at": r["updated_at"].strftime('%Y-%m-%d %H:%M:%S') if r["updated_at"] else None, + } for r in activities] + } + + +@router.get("/users/{user_id}/detail", summary="用户资产详情") +async def get_user_asset_detail( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取指定用户的资产详情""" + # User info + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # Asset stats + stats = _execute_raw(db, """ + SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE user_id = :uid AND status = 'active' + GROUP BY category + """, {"uid": user_id}) + + total = _execute_raw(db, """ + SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE user_id = :uid AND status = 'active' + """, {"uid": user_id}) + + # Quota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + + return { + "code": 0, + "data": { + "user": { + "id": user.id, + "username": user.username, + "avatar": user.avatar, + "membership_level": user.membership_level, + }, + "total_assets": total[0]["cnt"] if total else 0, + "total_size": total[0]["sz"] if total else 0, + "total_size_formatted": _format_size(total[0]["sz"]) if total else "0 B", + "by_category": [{ + "category": r["category"], + "label": CATEGORY_LABELS.get(r["category"], r["category"]), + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in stats], + "quota": { + "quota_bytes": quota.quota_bytes if quota else 0, + "used_bytes": quota.used_bytes if quota else 0, + "quota_formatted": _format_size(quota.quota_bytes) if quota else "0 B", + "used_formatted": _format_size(quota.used_bytes) if quota else "0 B", + "usage_percent": round((quota.used_bytes / quota.quota_bytes * 100), 2) if quota and quota.quota_bytes > 0 else 0, + } if quota else None, + } + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4af19dd..8712b40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ const AdminAutomation = lazy(() => import('@/pages/admin/Automation')); const AdminDeveloper = lazy(() => import('@/pages/admin/Developer')); const AdminMcp = lazy(() => import('@/pages/admin/Mcp')); const AdminStorage = lazy(() => import('@/pages/admin/Storage')); +const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter')); const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow')); // Loading fallback @@ -128,6 +129,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9f5e8b2..eed55e0 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -509,7 +509,11 @@ export const adminAPI = { syncAdminKnowledge: (kbId: string, data: any) => post(`/admin/knowledge/external/sync/${kbId}`, data), batchDeleteAdminKnowledge: (ids: string[]) => post('/admin/knowledge/batch/delete', ids), // OSS存储配置 (Phase 3.1) - getAdminStorageStats: () => get('/admin/storage/stats/overview'), + getAssetStats: () => get('/admin/assets/stats'), + getAdminAssets: (query: string) => get(`/admin/assets/?${query}`), + deleteAdminAsset: (id: string) => del(`/admin/assets/${id}`), + getAssetActivity: () => get('/admin/assets/activity'), + getAdminStorageStats: () => get('/admin/storage/stats/overview'), getAdminStorageConfigs: (params?: any) => { const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; return get(`/admin/storage/${query}`); diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index f839239..a8ddf63 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -56,6 +56,7 @@ const navGroups: NavGroup[] = [ { path: '/admin/settings', icon: 'settings', label: '站点设置' }, { path: '/admin/logs', icon: 'logs', label: '日志管理' }, { path: '/admin/storage', icon: 'storage', label: '存储管理' }, + { path: '/admin/asset-center', icon: 'database', label: '资产中心' }, { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/developer', icon: 'developer', label: '开发者工具' }, ],