- 新增许可证服务器 (license-server/) - 新增 Agent 部署模块 (flnxi-agent/) - 添加许可证验证和激活页面 - 添加 Harbor 镜像同步脚本 - 更新素材系统支持许可证验证
218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
|
|
interface DashboardStats {
|
|
customers: { total: number };
|
|
licenses: { total: number; byType: Record<string, number>; byStatus: Record<string, number> };
|
|
activations: { total: number; active: number };
|
|
alerts: { pending: number };
|
|
recentActivations: Array<{
|
|
id: string;
|
|
licenseKey: string;
|
|
licenseType: string;
|
|
activatedAt: string;
|
|
companyName: string | null;
|
|
}>;
|
|
}
|
|
|
|
export default function AdminDashboard() {
|
|
const router = useRouter();
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchStats();
|
|
}, []);
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const res = await fetch('/api/admin/dashboard');
|
|
if (res.status === 401) {
|
|
router.push('/admin/login');
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
setStats(data);
|
|
} catch (error) {
|
|
console.error('获取统计数据失败:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await fetch('/api/admin/auth/logout', { method: 'POST' });
|
|
router.push('/admin/login');
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
|
<div className="text-gray-500">加载中...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100">
|
|
{/* 顶部导航 */}
|
|
<nav className="bg-white shadow">
|
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
|
<h1 className="text-xl font-bold">许可证管理后台</h1>
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/admin/customers" className="text-gray-600 hover:text-gray-900">
|
|
客户管理
|
|
</Link>
|
|
<Link href="/admin/licenses" className="text-gray-600 hover:text-gray-900">
|
|
许可证管理
|
|
</Link>
|
|
<Link href="/admin/alerts" className="text-gray-600 hover:text-gray-900">
|
|
告警中心
|
|
{stats?.alerts.pending ? (
|
|
<span className="ml-1 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
|
{stats.alerts.pending}
|
|
</span>
|
|
) : null}
|
|
</Link>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="text-gray-600 hover:text-gray-900"
|
|
>
|
|
退出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
{/* 统计卡片 */}
|
|
<div className="grid grid-cols-4 gap-6 mb-8">
|
|
<StatCard title="客户总数" value={stats?.customers.total || 0} />
|
|
<StatCard title="许可证总数" value={stats?.licenses.total || 0} />
|
|
<StatCard title="活跃激活" value={stats?.activations.active || 0} />
|
|
<StatCard
|
|
title="待处理告警"
|
|
value={stats?.alerts.pending || 0}
|
|
highlight={!!stats?.alerts.pending}
|
|
/>
|
|
</div>
|
|
|
|
{/* 许可证分布 */}
|
|
<div className="grid grid-cols-2 gap-6 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium mb-4">许可证类型分布</h3>
|
|
<div className="space-y-3">
|
|
{Object.entries(stats?.licenses.byType || {}).map(([type, count]) => (
|
|
<div key={type} className="flex justify-between items-center">
|
|
<span className="text-gray-600">{type}</span>
|
|
<span className="font-medium">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium mb-4">许可证状态分布</h3>
|
|
<div className="space-y-3">
|
|
{Object.entries(stats?.licenses.byStatus || {}).map(([status, count]) => (
|
|
<div key={status} className="flex justify-between items-center">
|
|
<span className="text-gray-600">
|
|
{status === 'active' ? '正常' : status === 'disabled' ? '已禁用' : '已过期'}
|
|
</span>
|
|
<span className="font-medium">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 最近激活 */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="p-6 border-b">
|
|
<h3 className="text-lg font-medium">最近激活</h3>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
许可证密钥
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
类型
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
客户
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
激活时间
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{stats?.recentActivations.map((activation) => (
|
|
<tr key={activation.id}>
|
|
<td className="px-6 py-4 font-mono text-sm">{activation.licenseKey}</td>
|
|
<td className="px-6 py-4">
|
|
<TypeBadge type={activation.licenseType} />
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-600">
|
|
{activation.companyName || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-600">
|
|
{new Date(activation.activatedAt).toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!stats?.recentActivations.length && (
|
|
<tr>
|
|
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
|
暂无激活记录
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
highlight = false,
|
|
}: {
|
|
title: string;
|
|
value: number;
|
|
highlight?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`bg-white rounded-lg shadow p-6 ${highlight ? 'ring-2 ring-red-500' : ''}`}>
|
|
<div className="text-sm text-gray-500 mb-1">{title}</div>
|
|
<div className={`text-3xl font-bold ${highlight ? 'text-red-600' : 'text-gray-900'}`}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TypeBadge({ type }: { type: string }) {
|
|
const colors: Record<string, string> = {
|
|
FREE: 'bg-gray-100 text-gray-700',
|
|
STANDARD: 'bg-blue-100 text-blue-700',
|
|
PRO: 'bg-purple-100 text-purple-700',
|
|
};
|
|
|
|
return (
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
|
{type}
|
|
</span>
|
|
);
|
|
}
|