WuYongJie ba98bf22cc feat: 添加许可证系统、Agent部署模块和Harbor镜像管理
- 新增许可证服务器 (license-server/)
- 新增 Agent 部署模块 (flnxi-agent/)
- 添加许可证验证和激活页面
- 添加 Harbor 镜像同步脚本
- 更新素材系统支持许可证验证
2025-12-15 21:19:46 +08:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
interface License {
id: string;
licenseKey: string;
licenseType: string;
status: string;
expiresAt: string | null;
createdAt: string;
customer: { companyName: string } | null;
_count: { activations: number };
}
interface Customer {
id: string;
companyName: string;
}
export default function LicensesPage() {
const router = useRouter();
const [licenses, setLicenses] = useState<License[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const [licensesRes, customersRes] = await Promise.all([
fetch('/api/admin/licenses'),
fetch('/api/admin/customers'),
]);
if (licensesRes.status === 401) {
router.push('/admin/login');
return;
}
const licensesData = await licensesRes.json();
const customersData = await customersRes.json();
setLicenses(licensesData.licenses || []);
setCustomers(customersData.customers || []);
} catch (error) {
console.error('获取数据失败:', error);
} finally {
setLoading(false);
}
};
const filteredLicenses = licenses.filter(
(l) =>
l.licenseKey.toLowerCase().includes(search.toLowerCase()) ||
l.customer?.companyName.toLowerCase().includes(search.toLowerCase())
);
const handleDisable = async (id: string) => {
if (!confirm('确定要禁用此许可证吗?')) return;
try {
const res = await fetch(`/api/admin/licenses/${id}/disable`, { method: 'POST' });
if (res.ok) {
fetchData();
}
} catch (error) {
console.error('禁用失败:', error);
}
};
const handleReset = async (id: string) => {
if (!confirm('确定要重置硬件绑定吗?这将允许在新设备上激活。')) return;
try {
const res = await fetch(`/api/admin/licenses/${id}/reset`, { method: 'POST' });
if (res.ok) {
fetchData();
}
} catch (error) {
console.error('重置失败:', error);
}
};
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">
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
← 返回
</Link>
<h1 className="text-xl font-bold">许可证管理</h1>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b flex justify-between items-center">
<input
type="text"
placeholder="搜索许可证..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-4 py-2 border rounded-lg w-64"
/>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
生成许可证
</button>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500">加载中...</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>
<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">
{filteredLicenses.map((license) => (
<tr key={license.id}>
<td className="px-6 py-4 font-mono text-sm">{license.licenseKey}</td>
<td className="px-6 py-4">
<TypeBadge type={license.licenseType} />
</td>
<td className="px-6 py-4">
<StatusBadge status={license.status} />
</td>
<td className="px-6 py-4 text-gray-600">
{license.customer?.companyName || '-'}
</td>
<td className="px-6 py-4">{license._count.activations}</td>
<td className="px-6 py-4 text-gray-600">
{license.expiresAt
? new Date(license.expiresAt).toLocaleDateString()
: '永久'}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
{license.status === 'active' && (
<button
onClick={() => handleDisable(license.id)}
className="text-red-600 hover:underline text-sm"
>
禁用
</button>
)}
<button
onClick={() => handleReset(license.id)}
className="text-blue-600 hover:underline text-sm"
>
重置绑定
</button>
</div>
</td>
</tr>
))}
{!filteredLicenses.length && (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
暂无许可证数据
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</main>
{showModal && (
<LicenseModal
customers={customers}
onClose={() => setShowModal(false)}
onSuccess={() => {
setShowModal(false);
fetchData();
}}
/>
)}
</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>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
active: 'bg-green-100 text-green-700',
disabled: 'bg-red-100 text-red-700',
expired: 'bg-yellow-100 text-yellow-700',
};
const labels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
expired: '已过期',
};
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[status] || colors.active}`}>
{labels[status] || status}
</span>
);
}
function LicenseModal({
customers,
onClose,
onSuccess,
}: {
customers: Customer[];
onClose: () => void;
onSuccess: () => void;
}) {
const [form, setForm] = useState({
customerId: '',
licenseType: 'STANDARD',
expiresInDays: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [generatedKey, setGeneratedKey] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/admin/licenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId: form.customerId || null,
licenseType: form.licenseType,
expiresInDays: form.expiresInDays ? parseInt(form.expiresInDays) : null,
}),
});
const data = await res.json();
if (data.success) {
setGeneratedKey(data.license.licenseKey);
} else {
setError(data.error || '生成失败');
}
} catch {
setError('网络错误');
} finally {
setLoading(false);
}
};
if (generatedKey) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 className="text-lg font-medium mb-4">许可证已生成</h2>
<div className="bg-gray-100 p-4 rounded-lg mb-4">
<p className="text-sm text-gray-500 mb-2">许可证密钥:</p>
<p className="font-mono text-lg font-bold text-center">{generatedKey}</p>
</div>
<p className="text-sm text-gray-500 mb-4">请妥善保存此密钥,它只会显示一次。</p>
<button
onClick={onSuccess}
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
完成
</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
<div className="p-6 border-b">
<h2 className="text-lg font-medium">生成许可证</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
关联客户
</label>
<select
value={form.customerId}
onChange={(e) => setForm({ ...form, customerId: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">不关联客户</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.companyName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
许可证类型 *
</label>
<select
value={form.licenseType}
onChange={(e) => setForm({ ...form, licenseType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="FREE">免费版</option>
<option value="STANDARD">标准版</option>
<option value="PRO">专业版</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
有效期(天)
</label>
<input
type="number"
value={form.expiresInDays}
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
placeholder="留空表示永久有效"
className="w-full px-3 py-2 border rounded-lg"
min="1"
/>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
>
取消
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '生成中...' : '生成'}
</button>
</div>
</form>
</div>
</div>
);
}