增强后台数据驾驶舱:6张指标卡(近7天活跃/订阅成功/文档数等)+30天Token趋势+操作/审计日志分页与CSV下载

This commit is contained in:
QihuAI Design committed 2026-07-14 12:00:19 +08:00
1 parent 6525b4d994
commit fcc84d98df
3 files changed
+313 -61

No files matched your search

+209 -40
View File
@@ -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]
+7
View File
@@ -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<any>(`/admin/dashboard/operation-logs?page=${page}&page_size=${pageSize}`),
getDashboardAuditLogs: (page = 1, pageSize = 20) =>
get<any>(`/admin/dashboard/audit-logs?page=${page}&page_size=${pageSize}`),
// 用户管理
getUsers: (params?: any) => {
+97 -21
View File
@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '12px 16px', fontSize: 12, color: '#6B7280' }}>
<span>共 {total} 条</span>
<button disabled={page <= 1} onClick={() => onPage(page - 1)} style={pagBtnStyle(page <= 1)}>上一页</button>
<span style={{ fontFamily: 'var(--font-mono)', color: '#374151' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPage(page + 1)} style={pagBtnStyle(page >= totalPages)}>下一页</button>
</div>
);
};
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats>({
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<DailyStat[]>([]);
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
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: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ 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: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ 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: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
{ 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: <SVGIcon size={22}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon> },
{ 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: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
{ 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: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
@@ -312,8 +380,11 @@ const Dashboard: React.FC = () => {
{/* Recent Logs */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
<h3 className="admin-card-title">操作日志</h3>
<button
onClick={() => downloadCsv('/admin/dashboard/operation-logs/export', 'operation_logs.csv')}
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
>下载 CSV</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
@@ -343,13 +414,17 @@ const Dashboard: React.FC = () => {
</tbody>
</table>
</div>
<LogPagination total={opTotal} page={opPage} pageSize={PAGE_SIZE} onPage={loadOperationLogs} />
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
<h3 className="admin-card-title">审计日志</h3>
<button
onClick={() => downloadCsv('/admin/dashboard/audit-logs/export', 'audit_logs.csv')}
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
>下载 CSV</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
@@ -377,6 +452,7 @@ const Dashboard: React.FC = () => {
</tbody>
</table>
</div>
<LogPagination total={auditTotal} page={auditPage} pageSize={PAGE_SIZE} onPage={loadAuditLogs} />
</div>
</div>
);