diff --git a/license-server/.dockerignore b/license-server/.dockerignore new file mode 100644 index 0000000..661ce4c --- /dev/null +++ b/license-server/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +coverage +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +tsconfig.tsbuildinfo +.git +.gitignore diff --git a/license-server/.env.example b/license-server/.env.example index dfa16db..c3a0a2b 100644 --- a/license-server/.env.example +++ b/license-server/.env.example @@ -1,15 +1,27 @@ -# 数据库连接 -DATABASE_URL="postgresql://postgres:password@localhost:5432/license_server?schema=public" +# PostgreSQL(Docker Compose 部署时也会直接使用这三个变量) +POSTGRES_USER="license" +POSTGRES_PASSWORD="replace-with-strong-postgres-password" +POSTGRES_DB="license_db" -# RSA 密钥(生产环境请使用 generate-keys 脚本生成) +# 数据库连接 +# 主数据库(管理项目和用户) +MAIN_DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=main" +# 主库加密密钥(用于加密项目数据库密码,至少 32 字节) +MAIN_DB_ENCRYPTION_KEY="replace-with-32-byte-or-longer-encryption-key" + +# 项目数据库(默认项目,兼容旧配置) +DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=public" + +# RSA 密钥(生产环境必须使用 generate-keys 脚本生成,不要使用默认值) RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" # AES 加密密钥(32字节 hex) -AES_SECRET_KEY="your-32-byte-hex-key-here-12345678901234567890" +AES_SECRET_KEY="replace-with-64-char-hex-key" -# JWT 密钥 -JWT_SECRET="your-jwt-secret-key" +# JWT 密钥(生产环境必须配置,最少 32 字节) +# 警告:未配置或长度不足将导致管理员认证功能不可用 +JWT_SECRET="your-jwt-secret-key-at-least-32-bytes-long" -# 管理员初始密码 -ADMIN_INITIAL_PASSWORD="admin123" +# 管理员初始密码(只作为初始化入口,不要保留弱默认值) +ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password" diff --git a/license-server/Dockerfile b/license-server/Dockerfile index 14bfe7b..bcf4eca 100644 --- a/license-server/Dockerfile +++ b/license-server/Dockerfile @@ -16,12 +16,10 @@ RUN npm config set registry https://registry.npmmirror.com && \ # 复制源码 COPY . . +RUN mkdir -p public -# 生成 Prisma Client -RUN node node_modules/prisma/build/index.js generate - -# 构建(直接用 node 执行 next) -RUN node node_modules/next/dist/bin/next build +# 构建(会通过 prebuild 自动生成双 Prisma Client) +RUN npm run build # 生产阶段 FROM node:20-slim AS runner diff --git a/license-server/app/admin/[projectCode]/alerts/page.tsx b/license-server/app/admin/[projectCode]/alerts/page.tsx new file mode 100644 index 0000000..a637a48 --- /dev/null +++ b/license-server/app/admin/[projectCode]/alerts/page.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; + +interface Alert { + id: string; + alertType: string; + status: string; + details: Record | null; + createdAt: string; + resolvedAt: string | null; + license: { + licenseKey: string; + customer: { companyName: string } | null; + }; +} + +export default function ProjectAlertsPage() { + const router = useRouter(); + const params = useParams(); + const projectCode = params.projectCode as string; + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'all' | 'pending' | 'resolved'>('pending'); + + const loadAlerts = async () => { + try { + const res = await fetch(`/api/admin/${projectCode}/alerts?status=${filter}`); + + if (res.status === 401) { + router.push('/admin/login'); + return; + } + + if (res.status === 403 || res.status === 404) { + router.push('/admin/select-project'); + return; + } + + const data = await res.json(); + setAlerts(data.alerts || []); + } catch (error) { + console.error('获取告警列表失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadAlerts(); + }, [filter, projectCode]); + + const handleResolve = async (id: string) => { + try { + const res = await fetch(`/api/admin/${projectCode}/alerts/${id}/resolve`, { + method: 'POST', + }); + if (res.ok) { + await loadAlerts(); + } + } catch (error) { + console.error('处理告警失败:', error); + } + }; + + const handleIgnore = async (id: string) => { + try { + const res = await fetch(`/api/admin/${projectCode}/alerts/${id}/ignore`, { + method: 'POST', + }); + if (res.ok) { + await loadAlerts(); + } + } catch (error) { + console.error('忽略告警失败:', error); + } + }; + + const getAlertTypeLabel = (type: string) => { + const labels: Record = { + multi_device: '多设备激活', + fingerprint_mismatch: '指纹不匹配', + suspicious_activity: '可疑活动', + }; + return labels[type] || type; + }; + + return ( +
+ + +
+
+
+
+ + + +
+
+ + {loading ? ( +
加载中...
+ ) : ( +
+ {alerts.map((alert) => ( +
+
+
+
+ + {alert.status === 'pending' + ? '待处理' + : alert.status === 'resolved' + ? '已处理' + : '已忽略'} + + {getAlertTypeLabel(alert.alertType)} +
+

+ 许可证: {alert.license.licenseKey} +

+ {alert.license.customer && ( +

+ 客户: {alert.license.customer.companyName} +

+ )} +

+ 时间: {new Date(alert.createdAt).toLocaleString()} +

+ {alert.details && ( +
+ + 查看详情 + +
+                            {JSON.stringify(alert.details, null, 2)}
+                          
+
+ )} +
+ {alert.status === 'pending' && ( +
+ + +
+ )} +
+
+ ))} + {!alerts.length &&
暂无告警
} +
+ )} +
+
+
+ ); +} diff --git a/license-server/app/admin/[projectCode]/api-keys/page.tsx b/license-server/app/admin/[projectCode]/api-keys/page.tsx new file mode 100644 index 0000000..231ee95 --- /dev/null +++ b/license-server/app/admin/[projectCode]/api-keys/page.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; + +interface ApiKeyItem { + id: string; + name: string; + isActive: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +interface AdminInfo { + id: string; + username: string; + isSuperAdmin: boolean; +} + +export default function ProjectApiKeysPage() { + const router = useRouter(); + const params = useParams(); + const projectCode = params.projectCode as string; + + const [admin, setAdmin] = useState(null); + const [apiKeys, setApiKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + + const loadData = async () => { + try { + const adminRes = await fetch('/api/admin/me'); + if (adminRes.status === 401) { + router.push('/admin/login'); + return; + } + + const adminData = await adminRes.json(); + if (!adminData.isSuperAdmin) { + router.push(`/admin/${projectCode}`); + return; + } + setAdmin(adminData); + + const apiKeyRes = await fetch(`/api/admin/${projectCode}/api-keys`); + if (apiKeyRes.status === 403 || apiKeyRes.status === 404) { + router.push('/admin/select-project'); + return; + } + + const data = await apiKeyRes.json(); + setApiKeys(data.apiKeys || []); + } catch (error) { + console.error('获取项目 API Key 失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadData(); + }, [projectCode]); + + const handleDisable = async (id: string) => { + if (!confirm('确定要禁用这个 API Key 吗?')) { + return; + } + + try { + const response = await fetch(`/api/admin/${projectCode}/api-keys?id=${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + await loadData(); + } + } catch (error) { + console.error('禁用项目 API Key 失败:', error); + } + }; + + return ( +
+ + +
+
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + {apiKeys.map((item) => ( + + + + + + + + ))} + {!apiKeys.length ? ( + + + + ) : null} + +
+ 名称 + + 状态 + + 最近使用 + + 创建时间 + + 操作 +
{item.name} + + {item.isActive ? '启用中' : '已禁用'} + + + {item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString() : '未使用'} + + {new Date(item.createdAt).toLocaleString()} + + {item.isActive ? ( + + ) : ( + 不可恢复 + )} +
+ 暂无 API Key +
+ )} +
+
+ + {showModal ? ( + setShowModal(false)} + onSuccess={() => { + setShowModal(false); + void loadData(); + }} + /> + ) : null} +
+ ); +} + +function ApiKeyModal({ + projectCode, + onClose, + onSuccess, +}: { + projectCode: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [name, setName] = useState(''); + 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 response = await fetch(`/api/admin/${projectCode}/api-keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await response.json(); + + if (data.success) { + setGeneratedKey(data.key); + } else { + setError(data.error || '创建失败'); + } + } catch { + setError('网络错误'); + } finally { + setLoading(false); + } + }; + + if (generatedKey) { + return ( +
+
+

API Key 已创建

+
+

请立即保存,只会显示一次:

+

{generatedKey}

+
+ +
+
+ ); + } + + return ( +
+
+
+

创建项目 API Key

+
+
+
+ + setName(e.target.value)} + className="w-full rounded-lg border px-3 py-2" + placeholder="例如:官网发证" + required + /> +
+ {error ?
{error}
: null} +
+ + +
+
+
+
+ ); +} diff --git a/license-server/app/admin/[projectCode]/customers/page.tsx b/license-server/app/admin/[projectCode]/customers/page.tsx new file mode 100644 index 0000000..db340a7 --- /dev/null +++ b/license-server/app/admin/[projectCode]/customers/page.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; + +interface Customer { + id: string; + companyName: string; + contactName: string | null; + contactEmail: string | null; + contactPhone: string | null; + createdAt: string; + _count: { licenses: number }; +} + +export default function ProjectCustomersPage() { + const router = useRouter(); + const params = useParams(); + const projectCode = params.projectCode as string; + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showModal, setShowModal] = useState(false); + + const loadCustomers = async () => { + try { + const res = await fetch(`/api/admin/${projectCode}/customers`); + + if (res.status === 401) { + router.push('/admin/login'); + return; + } + + if (res.status === 403 || res.status === 404) { + router.push('/admin/select-project'); + return; + } + + const data = await res.json(); + setCustomers(data.customers || []); + } catch (error) { + console.error('获取客户列表失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadCustomers(); + }, [projectCode]); + + const filteredCustomers = customers.filter( + (customer) => + customer.companyName.toLowerCase().includes(search.toLowerCase()) || + customer.contactName?.toLowerCase().includes(search.toLowerCase()) || + customer.contactEmail?.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ + +
+
+
+ setSearch(e.target.value)} + className="w-64 rounded-lg border px-4 py-2" + /> + +
+ + {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + + {filteredCustomers.map((customer) => ( + + + + + + + + + ))} + {!filteredCustomers.length && ( + + + + )} + +
+ 公司名称 + + 联系人 + + 邮箱 + + 许可证数 + + 创建时间 + + 操作 +
{customer.companyName}{customer.contactName || '-'}{customer.contactEmail || '-'}{customer._count.licenses} + {new Date(customer.createdAt).toLocaleDateString()} + 详情页待补充
+ 暂无客户数据 +
+ )} +
+
+ + {showModal && ( + setShowModal(false)} + onSuccess={() => { + setShowModal(false); + void loadCustomers(); + }} + /> + )} +
+ ); +} + +function CustomerModal({ + projectCode, + onClose, + onSuccess, +}: { + projectCode: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [form, setForm] = useState({ + companyName: '', + contactName: '', + contactEmail: '', + contactPhone: '', + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const res = await fetch(`/api/admin/${projectCode}/customers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + + const data = await res.json(); + if (data.success) { + onSuccess(); + } else { + setError(data.error || '创建失败'); + } + } catch { + setError('网络错误'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

添加客户

+
+ +
+
+ + setForm({ ...form, companyName: e.target.value })} + className="w-full rounded-lg border px-3 py-2" + required + /> +
+ +
+ + setForm({ ...form, contactName: e.target.value })} + className="w-full rounded-lg border px-3 py-2" + /> +
+ +
+ + setForm({ ...form, contactEmail: e.target.value })} + className="w-full rounded-lg border px-3 py-2" + /> +
+ +
+ + setForm({ ...form, contactPhone: e.target.value })} + className="w-full rounded-lg border px-3 py-2" + /> +
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} diff --git a/license-server/app/admin/[projectCode]/licenses/page.tsx b/license-server/app/admin/[projectCode]/licenses/page.tsx new file mode 100644 index 0000000..82db925 --- /dev/null +++ b/license-server/app/admin/[projectCode]/licenses/page.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; + +interface License { + id: string; + licenseKey: string; + licenseType: string; + customerName: string | null; + status: string; + expiresAt: string | null; + createdAt: string; + _count: { activations: number }; +} + +export default function ProjectLicensesPage() { + const router = useRouter(); + const params = useParams(); + const projectCode = params.projectCode as string; + const [licenses, setLicenses] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showModal, setShowModal] = useState(false); + + const loadLicenses = async () => { + try { + const res = await fetch(`/api/admin/${projectCode}/licenses`); + + if (res.status === 401) { + router.push('/admin/login'); + return; + } + + if (res.status === 403 || res.status === 404) { + router.push('/admin/select-project'); + return; + } + + const data = await res.json(); + setLicenses(data.licenses || []); + } catch (error) { + console.error('获取数据失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadLicenses(); + }, [projectCode]); + + const filteredLicenses = licenses.filter( + (license) => + license.licenseKey.toLowerCase().includes(search.toLowerCase()) || + (license.customerName && + license.customerName.toLowerCase().includes(search.toLowerCase())) + ); + + const handleDisable = async (id: string) => { + if (!confirm('确定要禁用此许可证吗?')) { + return; + } + + try { + const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/disable`, { + method: 'POST', + }); + if (res.ok) { + await loadLicenses(); + } + } catch (error) { + console.error('禁用失败:', error); + } + }; + + const handleEnable = async (id: string) => { + if (!confirm('确定要启用此许可证吗?')) { + return; + } + + try { + const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/enable`, { + method: 'POST', + }); + if (res.ok) { + await loadLicenses(); + } + } catch (error) { + console.error('启用失败:', error); + } + }; + + const handleReset = async (id: string) => { + if (!confirm('确定要重置硬件绑定吗?这将允许在新设备上激活。')) { + return; + } + + try { + const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/reset`, { + method: 'POST', + }); + if (res.ok) { + await loadLicenses(); + } + } catch (error) { + console.error('重置失败:', error); + } + }; + + const handleDelete = async (id: string, licenseKey: string) => { + if ( + !confirm( + `确定要删除许可证 ${licenseKey} 吗?\n\n此操作将同时删除所有相关的激活记录、心跳记录和告警记录,且不可恢复!` + ) + ) { + return; + } + + try { + const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/delete`, { + method: 'DELETE', + }); + const data = await res.json(); + if (data.success) { + await loadLicenses(); + } else { + alert(data.error || '删除失败'); + } + } catch (error) { + console.error('删除失败:', error); + alert('删除失败'); + } + }; + + return ( +
+ + +
+
+
+ setSearch(e.target.value)} + className="w-64 rounded-lg border px-4 py-2" + /> + +
+ + {loading ? ( +
加载中...
+ ) : ( +
+ + + + + + + + + + + + + + {filteredLicenses.map((license) => ( + + + + + + + + + + ))} + {!filteredLicenses.length && ( + + + + )} + +
+ 许可证密钥 + + 类型 + + 状态 + + 客户名称 + + 激活数 + + 到期时间 + + 操作 +
{license.licenseKey} + + + + + {license.customerName || '-'} + {license._count.activations} + {license.expiresAt + ? new Date(license.expiresAt).toLocaleDateString() + : '永久'} + +
+ {license.status === 'active' ? ( + + ) : ( + + )} + + +
+
+ 暂无许可证数据 +
+
+ )} +
+
+ + {showModal && ( + setShowModal(false)} + onSuccess={() => { + setShowModal(false); + void loadLicenses(); + }} + /> + )} +
+ ); +} + +function TypeBadge({ type }: { type: string }) { + const colors: Record = { + FREE: 'bg-gray-100 text-gray-700', + STANDARD: 'bg-blue-100 text-blue-700', + PRO: 'bg-purple-100 text-purple-700', + }; + const labels: Record = { + FREE: '免费版', + STANDARD: '标准版', + PRO: '专业版', + }; + + return ( + + {labels[type] || type} + + ); +} + +function StatusBadge({ status }: { status: string }) { + const colors: Record = { + active: 'bg-green-100 text-green-700', + disabled: 'bg-red-100 text-red-700', + expired: 'bg-yellow-100 text-yellow-700', + }; + const labels: Record = { + active: '正常', + disabled: '已禁用', + expired: '已过期', + }; + + return ( + + {labels[status] || status} + + ); +} + +function LicenseModal({ + projectCode, + onClose, + onSuccess, +}: { + projectCode: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [form, setForm] = useState({ + customerName: '', + licenseType: 'STANDARD', + expiresInDays: '365', + }); + 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/${projectCode}/licenses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName: form.customerName || null, + licenseType: form.licenseType, + expiresInDays: form.expiresInDays ? parseInt(form.expiresInDays, 10) : 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 ( +
+
+

许可证已生成

+
+

许可证密钥:

+

{generatedKey}

+
+

+ 请妥善保存此密钥,发送给客户用于激活系统。 +

+ +
+
+ ); + } + + return ( +
+
+
+

生成许可证

+
+ +
+
+ + setForm({ ...form, customerName: e.target.value })} + placeholder="输入客户公司或个人名称" + className="w-full rounded-lg border px-3 py-2" + /> +

用于后台管理识别

+
+ +
+ + +
+ +
+ + setForm({ ...form, expiresInDays: e.target.value })} + placeholder="留空表示永久有效" + className="w-full rounded-lg border px-3 py-2" + min="1" + /> +
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} diff --git a/license-server/app/admin/[projectCode]/page.tsx b/license-server/app/admin/[projectCode]/page.tsx new file mode 100644 index 0000000..a730a85 --- /dev/null +++ b/license-server/app/admin/[projectCode]/page.tsx @@ -0,0 +1,297 @@ +'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; byStatus: Record }; + 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(null); + const [project, setProject] = useState(null); + const [admin, setAdmin] = useState(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 ( +
+
加载中...
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* 顶部导航 */} + + +
+ {/* 统计卡片 */} +
+ + + +
+ + {/* 许可证分布 */} +
+
+

许可证类型分布

+
+ {Object.entries(stats?.licenses.byType || {}).map(([type, count]) => ( +
+ + {type === 'FREE' ? '免费版' : type === 'STANDARD' ? '标准版' : '专业版'} + + {count} +
+ ))} +
+
+ +
+

许可证状态分布

+
+ {Object.entries(stats?.licenses.byStatus || {}).map(([status, count]) => ( +
+ + {status === 'active' ? '正常' : status === 'disabled' ? '已禁用' : '已过期'} + + {count} +
+ ))} +
+
+
+ + {/* 最近激活 */} +
+
+

最近激活

+
+
+ + + + + + + + + + + {stats?.recentActivations.map((activation) => ( + + + + + + + ))} + {!stats?.recentActivations?.length && ( + + + + )} + +
+ 许可证密钥 + + 类型 + + 客户名称 + + 激活时间 +
{activation.licenseKey} + + + {activation.customerName || '-'} + + {new Date(activation.activatedAt).toLocaleString()} +
+ 暂无激活记录 +
+
+
+
+
+ ); +} + +function StatCard({ + title, + value, + highlight = false, +}: { + title: string; + value: number; + highlight?: boolean; +}) { + return ( +
+
{title}
+
+ {value} +
+
+ ); +} + +function TypeBadge({ type }: { type: string }) { + const colors: Record = { + FREE: 'bg-gray-100 text-gray-700', + STANDARD: 'bg-blue-100 text-blue-700', + PRO: 'bg-purple-100 text-purple-700', + }; + const labels: Record = { + FREE: '免费版', + STANDARD: '标准版', + PRO: '专业版', + }; + + return ( + + {labels[type] || type} + + ); +} diff --git a/license-server/app/admin/[projectCode]/versions/page.tsx b/license-server/app/admin/[projectCode]/versions/page.tsx new file mode 100644 index 0000000..d7a8da2 --- /dev/null +++ b/license-server/app/admin/[projectCode]/versions/page.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; + +interface Version { + id: string; + version: string; + releaseNotes: string | null; + minRequiredVersion: string | null; + isSecurityUpdate: boolean; + isCriticalUpdate: boolean; + downloadSize: string | null; + releaseDate: string; +} + +export default function ProjectVersionsPage() { + const router = useRouter(); + const params = useParams(); + const projectCode = params.projectCode as string; + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + + const loadVersions = async () => { + try { + const res = await fetch(`/api/admin/${projectCode}/versions`); + + if (res.status === 401) { + router.push('/admin/login'); + return; + } + + if (res.status === 403 || res.status === 404) { + router.push('/admin/select-project'); + return; + } + + const data = await res.json(); + setVersions(data.versions || []); + } catch (error) { + console.error('获取版本列表失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadVersions(); + }, [projectCode]); + + return ( +
+ + +
+
+
+

版本列表

+ +
+ + {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + {versions.map((item) => ( + + + + + + + + ))} + {!versions.length && ( + + + + )} + +
+ 版本号 + + 发布日期 + + 最低要求版本 + + 标签 + + 更新说明 +
{item.version} + {new Date(item.releaseDate).toLocaleDateString()} + {item.minRequiredVersion || '-'} + {item.isSecurityUpdate && ( + + 安全更新 + + )} + {item.isCriticalUpdate && ( + + 重要更新 + + )} + + {item.releaseNotes || '-'} +
+ 暂无版本数据 +
+ )} +
+
+ + {showModal && ( + setShowModal(false)} + onSuccess={() => { + setShowModal(false); + void loadVersions(); + }} + /> + )} +
+ ); +} + +function VersionModal({ + projectCode, + onClose, + onSuccess, +}: { + projectCode: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [form, setForm] = useState({ + version: '', + releaseNotes: '', + minRequiredVersion: '', + isSecurityUpdate: false, + isCriticalUpdate: false, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const res = await fetch(`/api/admin/${projectCode}/versions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + const data = await res.json(); + if (data.success) { + onSuccess(); + } else { + setError(data.error || '发布失败'); + } + } catch { + setError('网络错误'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

发布新版本

+
+
+
+ + setForm({ ...form, version: e.target.value })} + className="w-full rounded-lg border px-3 py-2" + placeholder="1.0.0" + required + /> +
+
+ +