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: '开发者工具' },
],