增强后台数据驾驶舱:6张指标卡(近7天活跃/订阅成功/文档数等)+30天Token趋势+操作/审计日志分页与CSV下载
This commit is contained in:
1 parent
6525b4d994
commit
fcc84d98df
3 files changed
+313
-61
No files matched your search
@@ -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
|
|
||||||
]
|
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user