From fcc84d98dffe08d3334a4598d7455639487d6401 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Tue, 14 Jul 2026 12:00:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=90=8E=E5=8F=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A9=BE=E9=A9=B6=E8=88=B1=EF=BC=9A6=E5=BC=A0?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E5=8D=A1(=E8=BF=917=E5=A4=A9=E6=B4=BB?= =?UTF-8?q?=E8=B7=83/=E8=AE=A2=E9=98=85=E6=88=90=E5=8A=9F/=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=95=B0=E7=AD=89)+30=E5=A4=A9Token=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?+=E6=93=8D=E4=BD=9C/=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E4=B8=8ECSV=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/admin/dashboard.py | 249 ++++++++++++++++++---- frontend/src/api/index.ts | 7 + frontend/src/pages/admin/Dashboard.tsx | 118 ++++++++-- 3 files changed, 313 insertions(+), 61 deletions(-) diff --git a/backend/python/app/api/admin/dashboard.py b/backend/python/app/api/admin/dashboard.py index 95d6f0f..a3806b4 100644 --- a/backend/python/app/api/admin/dashboard.py +++ b/backend/python/app/api/admin/dashboard.py @@ -2,77 +2,258 @@ 管理员仪表盘 API """ from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import func from ...database import get_db, SessionLocal from ...models import ( - User, AIModel, Order, TokenUsageLog, OperationLog, AuditLog + User, AIModel, Order, TokenUsageLog, OperationLog, AuditLog, + Conversation, Message, KnowledgeBase, KnowledgeDocument, ) from ...auth import get_current_admin from ...middleware.log_service import LogService -from datetime import datetime, date +from datetime import datetime, date, timedelta, time +import csv +import io router = APIRouter() +def _serialize_op_log(log): + return { + "id": log.id, + "user_id": log.user_id, + "username": log.username, + "action": log.action, + "target": log.target, + "target_id": log.target_id, + "detail": log.detail, + "ip": log.ip, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + +def _serialize_audit_log(log): + return { + "id": log.id, + "admin_id": log.admin_id, + "admin_name": log.admin_name, + "action": log.action, + "target_type": log.target_type, + "target_id": log.target_id, + "detail": log.detail, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + @router.get("/stats") async def get_dashboard_stats( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取管理员仪表盘统计数据""" - today = date.today() + today_start = datetime.combine(date.today(), time.min) + seven_days_ago = datetime.now() - timedelta(days=7) - active_users = db.query(func.count(User.id)).filter( - User.status == "active" - ).scalar() or 0 + total_users = db.query(func.count(User.id)).scalar() or 0 + active_users = db.query( + func.count(func.distinct(Conversation.user_id)) + ).filter(Conversation.created_at >= seven_days_ago).scalar() or 0 + total_models = db.query(func.count(AIModel.id)).scalar() or 0 enabled_models = db.query(func.count(AIModel.id)).filter( AIModel.is_enabled == True ).scalar() or 0 + total_conversations = db.query(func.count(Conversation.id)).scalar() or 0 + active_conversations = db.query(func.count(Conversation.id)).filter( + Conversation.updated_at >= seven_days_ago + ).scalar() or 0 + valid_conversations = db.query(func.count(Conversation.id)).filter( + Conversation.id.in_(db.query(Message.conversation_id).distinct()) + ).scalar() or 0 + + total_knowledge_bases = db.query(func.count(KnowledgeBase.id)).scalar() or 0 + total_documents = db.query(func.count(KnowledgeDocument.id)).scalar() or 0 + today_orders = db.query(func.count(Order.id)).filter( - func.date(Order.created_at) == today + Order.status == "paid", + Order.paid_at >= today_start, + ).scalar() or 0 + today_subscription_orders = db.query(func.count(Order.id)).filter( + Order.order_type == "membership", + Order.status == "paid", + Order.paid_at >= today_start, ).scalar() or 0 - today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter( - func.date(TokenUsageLog.created_at) == today + today_conversations = db.query(func.count(Conversation.id)).filter( + Conversation.created_at >= today_start ).scalar() or 0 - total_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).scalar() or 0 + today_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).filter( + TokenUsageLog.created_at >= today_start + ).scalar() or 0 + week_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).filter( + TokenUsageLog.created_at >= seven_days_ago + ).scalar() or 0 + total_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).scalar() or 0 return { - "active_users": active_users, + "total_users": total_users, + "active_users": int(active_users), + "total_models": total_models, "enabled_models": enabled_models, + "total_conversations": total_conversations, + "active_conversations": int(active_conversations), + "valid_conversations": int(valid_conversations), + "total_knowledge_bases": total_knowledge_bases, + "total_documents": total_documents, "today_orders": today_orders, + "today_subscription_orders": today_subscription_orders, + "today_conversations": today_conversations, "today_tokens": int(today_tokens), + "week_tokens": int(week_tokens), "total_tokens": int(total_tokens), + "server_time": datetime.now().isoformat(), } +@router.get("/daily-stats") +async def get_daily_token_stats( + days: int = Query(30, ge=1, le=365), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取近 N 天 Token 消耗趋势(按天聚合)""" + end = date.today() + start = end - timedelta(days=days - 1) + start_dt = datetime.combine(start, time.min) + rows = db.query( + func.date(TokenUsageLog.created_at), + func.coalesce(func.sum(TokenUsageLog.total_tokens), 0), + ).filter(TokenUsageLog.created_at >= start_dt).group_by( + func.date(TokenUsageLog.created_at) + ).all() + m = {str(r[0]): int(r[1] or 0) for r in rows} + data = [] + for i in range(days): + d = start + timedelta(days=i) + ds = d.isoformat() + data.append({"date": ds, "tokens": m.get(ds, 0)}) + return {"code": 200, "data": {"days": days, "data": data}} + + +@router.get("/operation-logs") +async def list_operation_logs( + 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), +): + """分页获取操作日志(默认 20 条/页)""" + total = db.query(func.count(OperationLog.id)).scalar() or 0 + items = db.query(OperationLog).order_by( + OperationLog.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + return { + "code": 200, + "data": { + "items": [_serialize_op_log(l) for l in items], + "total": total, + "page": page, + "page_size": page_size, + }, + } + + +@router.get("/audit-logs") +async def list_audit_logs( + 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), +): + """分页获取审计日志(默认 20 条/页)""" + total = db.query(func.count(AuditLog.id)).scalar() or 0 + items = db.query(AuditLog).order_by( + AuditLog.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + return { + "code": 200, + "data": { + "items": [_serialize_audit_log(l) for l in items], + "total": total, + "page": page, + "page_size": page_size, + }, + } + + +@router.get("/operation-logs/export") +async def export_operation_logs( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """导出全部操作日志为 CSV""" + logs = db.query(OperationLog).order_by(OperationLog.created_at.desc()).all() + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["时间", "用户", "操作", "目标", "目标ID", "详情", "IP"]) + for l in logs: + writer.writerow([ + l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else "", + l.username or "", + l.action or "", + l.target or "", + l.target_id or "", + (l.detail or "").replace("\n", " ").replace("\r", ""), + l.ip or "", + ]) + content = buf.getvalue().encode("utf-8-sig") + return StreamingResponse( + io.BytesIO(content), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=operation_logs.csv"}, + ) + + +@router.get("/audit-logs/export") +async def export_audit_logs( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """导出全部审计日志为 CSV""" + logs = db.query(AuditLog).order_by(AuditLog.created_at.desc()).all() + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["时间", "操作人", "操作类型", "目标类型", "目标ID", "详情"]) + for l in logs: + writer.writerow([ + l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else "", + l.admin_name or "", + l.action or "", + l.target_type or "", + l.target_id or "", + (l.detail or "").replace("\n", " ").replace("\r", ""), + ]) + content = buf.getvalue().encode("utf-8-sig") + return StreamingResponse( + io.BytesIO(content), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=audit_logs.csv"}, + ) + + @router.get("/recent-logs") async def get_recent_logs( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): - """获取最近10条操作日志""" + """获取最近10条操作日志(兼容旧调用)""" logs = db.query(OperationLog).order_by( OperationLog.created_at.desc() ).limit(10).all() - return [ - { - "id": log.id, - "user_id": log.user_id, - "username": log.username, - "action": log.action, - "target": log.target, - "target_id": log.target_id, - "detail": log.detail, - "ip": log.ip, - "created_at": log.created_at.isoformat() if log.created_at else None, - } - for log in logs - ] + return [_serialize_op_log(log) for log in logs] @router.get("/recent-audit") @@ -80,20 +261,8 @@ async def get_recent_audit( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): - """获取最近10条审计日志""" + """获取最近10条审计日志(兼容旧调用)""" logs = db.query(AuditLog).order_by( AuditLog.created_at.desc() ).limit(10).all() - return [ - { - "id": log.id, - "admin_id": log.admin_id, - "admin_name": log.admin_name, - "action": log.action, - "target_type": log.target_type, - "target_id": log.target_id, - "detail": log.detail, - "created_at": log.created_at.isoformat() if log.created_at else None, - } - for log in logs - ] + return [_serialize_audit_log(log) for log in logs] diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 30d60e5..c33e87f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -501,6 +501,13 @@ export const adminAPI = { getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`), + // 驾驶舱分页日志 + getDashboardOperationLogs: (page = 1, pageSize = 20) => + get(`/admin/dashboard/operation-logs?page=${page}&page_size=${pageSize}`), + + getDashboardAuditLogs: (page = 1, pageSize = 20) => + get(`/admin/dashboard/audit-logs?page=${page}&page_size=${pageSize}`), + // 用户管理 getUsers: (params?: any) => { diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx index d4c96bd..b805550 100644 --- a/frontend/src/pages/admin/Dashboard.tsx +++ b/frontend/src/pages/admin/Dashboard.tsx @@ -15,9 +15,11 @@ interface DashboardStats { enabled_models: number; total_conversations: number; active_conversations: number; + valid_conversations: number; total_knowledge_bases: number; total_documents: number; today_orders: number; + today_subscription_orders: number; today_tokens: number; week_tokens: number; total_tokens: number; @@ -111,28 +113,100 @@ const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ va ); }; +const pagBtnStyle = (disabled: boolean): React.CSSProperties => ({ + border: '1px solid rgba(0,0,0,0.12)', + background: disabled ? '#F3F4F6' : '#FFFFFF', + color: disabled ? '#B0B3BD' : '#374151', + borderRadius: 6, + padding: '4px 10px', + fontSize: 12, + cursor: disabled ? 'not-allowed' : 'pointer', +}); + +const LogPagination: React.FC<{ total: number; page: number; pageSize: number; onPage: (p: number) => void }> = ({ total, page, pageSize, onPage }) => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return ( +
+ 共 {total} 条 + + {page} / {totalPages} + +
+ ); +}; + const Dashboard: React.FC = () => { const navigate = useNavigate(); const [stats, setStats] = useState({ total_users: 0, active_users: 0, total_models: 0, enabled_models: 0, - total_conversations: 0, active_conversations: 0, total_knowledge_bases: 0, total_documents: 0, - today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0, + total_conversations: 0, active_conversations: 0, valid_conversations: 0, total_knowledge_bases: 0, total_documents: 0, + today_orders: 0, today_subscription_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0, today_conversations: 0, server_time: '', }); const [dailyStats, setDailyStats] = useState([]); const [operationLogs, setOperationLogs] = useState([]); const [auditLogs, setAuditLogs] = useState([]); + const [opTotal, setOpTotal] = useState(0); + const [auditTotal, setAuditTotal] = useState(0); + const [opPage, setOpPage] = useState(1); + const [auditPage, setAuditPage] = useState(1); const [loading, setLoading] = useState(true); + const PAGE_SIZE = 20; + + const loadOperationLogs = async (page: number) => { + try { + const res: any = await (adminAPI as any).getDashboardOperationLogs(page, PAGE_SIZE); + const data = res?.data || res; + setOperationLogs(Array.isArray(data?.items) ? data.items : []); + setOpTotal(data?.total ?? 0); + setOpPage(page); + } catch (err) { + console.error('操作日志加载失败:', err); + } + }; + + const loadAuditLogs = async (page: number) => { + try { + const res: any = await (adminAPI as any).getDashboardAuditLogs(page, PAGE_SIZE); + const data = res?.data || res; + setAuditLogs(Array.isArray(data?.items) ? data.items : []); + setAuditTotal(data?.total ?? 0); + setAuditPage(page); + } catch (err) { + console.error('审计日志加载失败:', err); + } + }; + + const downloadCsv = async (url: string, filename: string) => { + try { + const token = localStorage.getItem('aiflow_token'); + const res = await fetch(`/app/api${url}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) throw new Error('下载失败'); + const blob = await res.blob(); + const href = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = href; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(href); + } catch (err) { + console.error('CSV下载失败:', err); + alert('下载失败,请稍后重试'); + } + }; + useEffect(() => { const fetchData = async () => { setLoading(true); try { - const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([ + const [statsRes, dailyRes] = await Promise.all([ adminAPI.getStats() as any, - (adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }), - adminAPI.getRecentLogs() as any, - adminAPI.getRecentAudit() as any, + (adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: { data: { data: [] } } }), ]); // Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself const statsData = statsRes?.data || statsRes; @@ -142,13 +216,7 @@ const Dashboard: React.FC = () => { const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || []; setDailyStats(Array.isArray(dailyData) ? dailyData : []); - // Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array - const logsData = Array.isArray(logsRes) ? logsRes : (Array.isArray(logsRes?.data) ? logsRes.data : []); - setOperationLogs(logsData); - - // Recent audit: same as logs - const auditData = Array.isArray(auditRes) ? auditRes : (Array.isArray(auditRes?.data) ? auditRes.data : []); - setAuditLogs(auditData); + await Promise.all([loadOperationLogs(1), loadAuditLogs(1)]); } catch (err) { console.error('Dashboard数据加载失败:', err); } finally { @@ -172,15 +240,15 @@ const Dashboard: React.FC = () => { }; const statCardDefs = [ - { label: '总用户', value: stats.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)', + { label: '总用户', value: stats.total_users, sub: `近7天活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)', icon: }, { label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)', icon: }, - { label: '对话数', value: stats.total_conversations, sub: `活跃 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)', + { label: '对话数', value: stats.valid_conversations, sub: `活跃窗口 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)', icon: }, - { label: '知识库', value: stats.total_knowledge_bases, sub: `文档 ${stats.total_documents}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)', + { label: '知识库文档', value: stats.total_documents, sub: `知识库 ${stats.total_knowledge_bases}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)', icon: }, - { label: '今日对话', value: stats.today_conversations, sub: `订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)', + { label: '今日订阅成功', value: stats.today_subscription_orders, sub: `今日订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)', icon: }, { label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true, icon: }, @@ -312,8 +380,11 @@ const Dashboard: React.FC = () => { {/* Recent Logs */}
-

最近操作日志

- 最近10条 +

操作日志

+
@@ -343,13 +414,17 @@ const Dashboard: React.FC = () => {
+
{/* Recent Audit Logs */}
-

最近审计日志

- 最近10条 +

审计日志

+
@@ -377,6 +452,7 @@ const Dashboard: React.FC = () => {
+
);