228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { adminAPI } from '@/api';
|
|
|
|
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
{children}
|
|
</svg>
|
|
);
|
|
|
|
const quickEntries = [
|
|
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><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> },
|
|
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
|
|
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
|
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
|
|
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
|
|
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
|
|
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
|
|
];
|
|
|
|
const Dashboard: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
|
|
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [statsRes, logsRes, auditRes] = await Promise.all([
|
|
adminAPI.getStats(),
|
|
adminAPI.getRecentLogs(),
|
|
adminAPI.getRecentAudit(),
|
|
]);
|
|
if (statsRes.data) setStats(statsRes.data);
|
|
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
|
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
|
} catch (err) {
|
|
console.error('Dashboard数据加载失败:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, []);
|
|
|
|
const formatDate = (d: string) => {
|
|
if (!d) return '-';
|
|
const dt = new Date(d);
|
|
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
|
|
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const formatTokens = (n: number): string => {
|
|
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
|
return n.toLocaleString('en-US');
|
|
};
|
|
|
|
const statCards = [
|
|
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', 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: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', 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.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
|
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
|
|
<div className="audit-spinner" />
|
|
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
|
<h1 className="admin-page-title">平台驾驶舱</h1>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="admin-stats">
|
|
{statCards.map((s, i) => (
|
|
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
|
|
<div style={{
|
|
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
|
|
background: s.bg, color: s.color,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}>
|
|
{s.icon}
|
|
</div>
|
|
<div>
|
|
<div className="admin-stat-label">{s.label}</div>
|
|
<div className="admin-stat-value">{s.value}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Platform Cumulative Token Stats */}
|
|
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">
|
|
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
|
{' '}平台累计Token消耗
|
|
</h3>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
|
|
{[
|
|
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
|
|
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
|
|
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
|
|
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
|
|
].map((item, i) => (
|
|
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
|
|
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
|
{item.value}
|
|
</div>
|
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
|
|
{item.label}
|
|
</div>
|
|
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Entry Cards */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
|
|
{quickEntries.map((entry) => (
|
|
<div
|
|
key={entry.path}
|
|
onClick={() => navigate(entry.path)}
|
|
style={{
|
|
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
|
|
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--color-accent)';
|
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--color-border)';
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}
|
|
>
|
|
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
|
|
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
|
|
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Recent Operation Logs */}
|
|
<div className="admin-card">
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">最近操作日志</h3>
|
|
</div>
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>用户</th>
|
|
<th>操作</th>
|
|
<th>目标</th>
|
|
<th>详情</th>
|
|
<th>IP</th>
|
|
<th>时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{operationLogs.length > 0 ? operationLogs.map(log => (
|
|
<tr key={log.id}>
|
|
<td style={{ fontWeight: 500 }}>{log.username}</td>
|
|
<td><span className="admin-badge badge-info">{log.action}</span></td>
|
|
<td>{log.target}</td>
|
|
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
|
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
|
|
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
|
</tr>
|
|
)) : (
|
|
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Audit Logs */}
|
|
<div className="admin-card">
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">最近审计日志</h3>
|
|
</div>
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>操作人</th>
|
|
<th>操作类型</th>
|
|
<th>目标类型</th>
|
|
<th>详情</th>
|
|
<th>时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{auditLogs.length > 0 ? auditLogs.map(log => (
|
|
<tr key={log.id}>
|
|
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
|
|
<td><span className="admin-badge badge-warning">{log.action}</span></td>
|
|
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
|
|
<td>{log.detail}</td>
|
|
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
|
|
</tr>
|
|
)) : (
|
|
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|