298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
|
|
interface DashboardStats {
|
|
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;
|
|
customerName: string | null;
|
|
activatedAt: string;
|
|
}>;
|
|
}
|
|
|
|
interface ProjectInfo {
|
|
code: string;
|
|
name: string;
|
|
}
|
|
|
|
interface AdminInfo {
|
|
id: string;
|
|
username: string;
|
|
isSuperAdmin: boolean;
|
|
}
|
|
|
|
export default function ProjectDashboard() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const projectCode = params.projectCode as string;
|
|
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [project, setProject] = useState<ProjectInfo | null>(null);
|
|
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [projectCode]);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
// 获取项目信息
|
|
const projectRes = await fetch(`/api/admin/projects/${projectCode}`);
|
|
if (projectRes.status === 401) {
|
|
router.push('/admin/login');
|
|
return;
|
|
}
|
|
if (projectRes.status === 404) {
|
|
setError('项目不存在或无权访问');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
const projectData = await projectRes.json();
|
|
setProject(projectData);
|
|
|
|
const adminRes = await fetch('/api/admin/me');
|
|
if (adminRes.ok) {
|
|
const adminData = await adminRes.json();
|
|
setAdmin(adminData);
|
|
}
|
|
|
|
// 获取统计数据
|
|
const statsRes = await fetch(`/api/admin/${projectCode}/dashboard`);
|
|
if (statsRes.ok) {
|
|
const statsData = await statsRes.json();
|
|
setStats(statsData);
|
|
}
|
|
} catch (err) {
|
|
console.error('获取数据失败:', err);
|
|
setError('加载失败');
|
|
} 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>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
|
<div className="text-center">
|
|
<p className="text-red-500 mb-4">{error}</p>
|
|
<button
|
|
onClick={() => router.push('/admin/select-project')}
|
|
className="text-blue-600 hover:underline"
|
|
>
|
|
返回项目选择
|
|
</button>
|
|
</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">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => router.push('/admin/select-project')}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
title="切换项目"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
<h1 className="text-xl font-bold">
|
|
{project?.name || projectCode}
|
|
<span className="text-sm font-normal text-gray-500 ml-2">许可证管理</span>
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Link href={`/admin/${projectCode}/licenses`} className="text-gray-600 hover:text-gray-900">
|
|
许可证管理
|
|
</Link>
|
|
<Link href={`/admin/${projectCode}/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>
|
|
<Link href={`/admin/${projectCode}/versions`} className="text-gray-600 hover:text-gray-900">
|
|
版本管理
|
|
</Link>
|
|
<Link href={`/admin/${projectCode}/customers`} className="text-gray-600 hover:text-gray-900">
|
|
客户管理
|
|
</Link>
|
|
{admin?.isSuperAdmin ? (
|
|
<Link href={`/admin/${projectCode}/api-keys`} className="text-gray-600 hover:text-gray-900">
|
|
API Key
|
|
</Link>
|
|
) : null}
|
|
<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-3 gap-6 mb-8">
|
|
<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 === 'FREE' ? '免费版' : type === 'STANDARD' ? '标准版' : '专业版'}
|
|
</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.customerName || '-'}
|
|
</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',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
FREE: '免费版',
|
|
STANDARD: '标准版',
|
|
PRO: '专业版',
|
|
};
|
|
|
|
return (
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
|
{labels[type] || type}
|
|
</span>
|
|
);
|
|
}
|