增强后台数据驾驶舱: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 管理员仪表盘 API
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from ...database import get_db, SessionLocal from ...database import get_db, SessionLocal
from ...models import ( 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 ...auth import get_current_admin
from ...middleware.log_service import LogService from ...middleware.log_service import LogService
from datetime import datetime, date from datetime import datetime, date, timedelta, time
import csv
import io
router = APIRouter() 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") @router.get("/stats")
async def get_dashboard_stats( async def get_dashboard_stats(
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
db: Session = Depends(get_db), 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( total_users = db.query(func.count(User.id)).scalar() or 0
User.status == "active" active_users = db.query(
).scalar() or 0 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( enabled_models = db.query(func.count(AIModel.id)).filter(
AIModel.is_enabled == True AIModel.is_enabled == True
).scalar() or 0 ).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( 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 ).scalar() or 0
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter( today_conversations = db.query(func.count(Conversation.id)).filter(
func.date(TokenUsageLog.created_at) == today Conversation.created_at >= today_start
).scalar() or 0 ).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 { return {
"active_users": active_users, "total_users": total_users,
"active_users": int(active_users),
"total_models": total_models,
"enabled_models": enabled_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_orders": today_orders,
"today_subscription_orders": today_subscription_orders,
"today_conversations": today_conversations,
"today_tokens": int(today_tokens), "today_tokens": int(today_tokens),
"week_tokens": int(week_tokens),
"total_tokens": int(total_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") @router.get("/recent-logs")
async def get_recent_logs( async def get_recent_logs(
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""获取最近10条操作日志""" """获取最近10条操作日志(兼容旧调用)"""
logs = db.query(OperationLog).order_by( logs = db.query(OperationLog).order_by(
OperationLog.created_at.desc() OperationLog.created_at.desc()
).limit(10).all() ).limit(10).all()
return [ return [_serialize_op_log(log) for log in logs]
{
"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
]
@router.get("/recent-audit") @router.get("/recent-audit")
@@ -80,20 +261,8 @@ async def get_recent_audit(
admin_user=Depends(get_current_admin), admin_user=Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""获取最近10条审计日志""" """获取最近10条审计日志(兼容旧调用)"""
logs = db.query(AuditLog).order_by( logs = db.query(AuditLog).order_by(
AuditLog.created_at.desc() AuditLog.created_at.desc()
).limit(10).all() ).limit(10).all()
return [ return [_serialize_audit_log(log) for log in logs]
{
"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
]
+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}` : ''}`), 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) => { getUsers: (params?: any) => {
+97 -21
View File
@@ -15,9 +15,11 @@ interface DashboardStats {
enabled_models: number; enabled_models: number;
total_conversations: number; total_conversations: number;
active_conversations: number; active_conversations: number;
valid_conversations: number;
total_knowledge_bases: number; total_knowledge_bases: number;
total_documents: number; total_documents: number;
today_orders: number; today_orders: number;
today_subscription_orders: number;
today_tokens: number; today_tokens: number;
week_tokens: number; week_tokens: number;
total_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 Dashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats>({ const [stats, setStats] = useState<DashboardStats>({
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0, 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, total_conversations: 0, active_conversations: 0, valid_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0, today_orders: 0, today_subscription_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
today_conversations: 0, server_time: '', today_conversations: 0, server_time: '',
}); });
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]); const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
const [operationLogs, setOperationLogs] = useState<any[]>([]); const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = 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 [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(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([ const [statsRes, dailyRes] = await Promise.all([
adminAPI.getStats() as any, adminAPI.getStats() as any,
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }), (adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: { data: { data: [] } } }),
adminAPI.getRecentLogs() as any,
adminAPI.getRecentAudit() as any,
]); ]);
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself // Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
const statsData = statsRes?.data || statsRes; const statsData = statsRes?.data || statsRes;
@@ -142,13 +216,7 @@ const Dashboard: React.FC = () => {
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || []; const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
setDailyStats(Array.isArray(dailyData) ? dailyData : []); setDailyStats(Array.isArray(dailyData) ? dailyData : []);
// Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array await Promise.all([loadOperationLogs(1), loadAuditLogs(1)]);
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);
} catch (err) { } catch (err) {
console.error('Dashboard数据加载失败:', err); console.error('Dashboard数据加载失败:', err);
} finally { } finally {
@@ -172,15 +240,15 @@ const Dashboard: React.FC = () => {
}; };
const statCardDefs = [ 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> }, 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)', { 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> }, 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> }, 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> }, 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> }, 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, { 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> }, 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 */} {/* Recent Logs */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}> <div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header"> <div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3> <h3 className="admin-card-title">操作日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span> <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>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className="admin-table"> <table className="admin-table">
@@ -343,13 +414,17 @@ const Dashboard: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<LogPagination total={opTotal} page={opPage} pageSize={PAGE_SIZE} onPage={loadOperationLogs} />
</div> </div>
{/* Recent Audit Logs */} {/* Recent Audit Logs */}
<div className="admin-card"> <div className="admin-card">
<div className="admin-card-header"> <div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3> <h3 className="admin-card-title">审计日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span> <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>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className="admin-table"> <table className="admin-table">
@@ -377,6 +452,7 @@ const Dashboard: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<LogPagination total={auditTotal} page={auditPage} pageSize={PAGE_SIZE} onPage={loadAuditLogs} />
</div> </div>
</div> </div>
); );