增强后台数据驾驶舱:6张指标卡(近7天活跃/订阅成功/文档数等)+30天Token趋势+操作/审计日志分页与CSV下载
This commit is contained in:
1 parent
6525b4d994
commit
fcc84d98df
3 files changed
+335
-83
No files matched your search
@@ -2,65 +2,25 @@
|
||||
管理员仪表盘 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()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取管理员仪表盘统计数据"""
|
||||
today = date.today()
|
||||
|
||||
active_users = db.query(func.count(User.id)).filter(
|
||||
User.status == "active"
|
||||
).scalar() or 0
|
||||
|
||||
enabled_models = db.query(func.count(AIModel.id)).filter(
|
||||
AIModel.is_enabled == True
|
||||
).scalar() or 0
|
||||
|
||||
today_orders = db.query(func.count(Order.id)).filter(
|
||||
func.date(Order.created_at) == today
|
||||
).scalar() or 0
|
||||
|
||||
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter(
|
||||
func.date(TokenUsageLog.created_at) == today
|
||||
).scalar() or 0
|
||||
|
||||
total_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).scalar() or 0
|
||||
|
||||
def _serialize_op_log(log):
|
||||
return {
|
||||
"active_users": active_users,
|
||||
"enabled_models": enabled_models,
|
||||
"today_orders": today_orders,
|
||||
"today_tokens": int(today_tokens),
|
||||
"total_tokens": int(total_tokens),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-logs")
|
||||
async def get_recent_logs(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取最近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,
|
||||
@@ -71,21 +31,10 @@ async def get_recent_logs(
|
||||
"ip": log.ip,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/recent-audit")
|
||||
async def get_recent_audit(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取最近10条审计日志"""
|
||||
logs = db.query(AuditLog).order_by(
|
||||
AuditLog.created_at.desc()
|
||||
).limit(10).all()
|
||||
return [
|
||||
{
|
||||
def _serialize_audit_log(log):
|
||||
return {
|
||||
"id": log.id,
|
||||
"admin_id": log.admin_id,
|
||||
"admin_name": log.admin_name,
|
||||
@@ -95,5 +44,225 @@ async def get_recent_audit(
|
||||
"detail": log.detail,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取管理员仪表盘统计数据"""
|
||||
today_start = datetime.combine(date.today(), time.min)
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
|
||||
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(
|
||||
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_conversations = db.query(func.count(Conversation.id)).filter(
|
||||
Conversation.created_at >= today_start
|
||||
).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 {
|
||||
"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条操作日志(兼容旧调用)"""
|
||||
logs = db.query(OperationLog).order_by(
|
||||
OperationLog.created_at.desc()
|
||||
).limit(10).all()
|
||||
return [_serialize_op_log(log) for log in logs]
|
||||
|
||||
|
||||
@router.get("/recent-audit")
|
||||
async def get_recent_audit(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取最近10条审计日志(兼容旧调用)"""
|
||||
logs = db.query(AuditLog).order_by(
|
||||
AuditLog.created_at.desc()
|
||||
).limit(10).all()
|
||||
return [_serialize_audit_log(log) 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}` : ''}`),
|
||||
|
||||
// 驾驶舱分页日志
|
||||
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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user